Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "doperpowers",
"description": "Emerges by humans, Converges by Agent",
"version": "7.172.0",
"version": "7.174.0",
"source": "./",
"author": {
"name": "SSFSKIM",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "doperpowers",
"description": "Emerges by humans, Converges by Agents",
"version": "7.172.0",
"version": "7.174.0",
"author": {
"name": "SSFSKIM",
"email": "supremekim17@gmail.com"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "doperpowers",
"version": "7.172.0",
"version": "7.174.0",
"description": "One software-development methodology for coding agents, sized by the work: brainstorm, design, spec, TDD, review, ship — one spec that carries its plan of work as milestones: one milestone runs in order from the spec, several run a fresh subagent per milestone reviewed at each dependency frontier, and the same method runs unattended on the board loop.",
"author": {
"name": "SSFSKIM",
Expand Down
11 changes: 7 additions & 4 deletions docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,12 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**.

- **File:** `$SMINOS_HOME/bridge/<session-id>.json`, mode 0600, written whole
(temporary name, then rename) whenever it changes:
`{"override": {"model": <id or null>, "effort": <effort or null>} | null, "delivered": {"<writer>": <seq>}}`.
`{"override": {"model": <id or null>, "effort": <effort or null>} | null, "delivered": {"<mark>": <seq>}}`.
**[#229]** A mark is `<project>/<writer>`, or the bare `<writer>` for a key naming no
project (`deliver`, below); the marks kept before keys named their project are bare,
and are kept as they are.
- **Written from the mod's events:** `model` (sets `override`; null when both are null),
`delivered` with outcome `submitted` (raises the writer's seq), `resync` (replaces
`delivered` with outcome `submitted` (raises the key's mark), `resync` (replaces
`override` and `delivered` with the mod's copy).
- **[fw1] A failed write is not taken as kept.** The child holds the mod's copy in memory and
writes it whole whenever it differs from what the file holds; what the file holds moves
Expand Down Expand Up @@ -463,7 +466,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**.
| `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [<task ids>], running: [<task ids>]}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: <id>`; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task <id>, the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] <the note's text>` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort |
| `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT_<FAMILY>_MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child |
| `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` |
| `deliver` | M2 **[M2]** **[fw1]** | `{key: {writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. `text` is submitted with `$.prompt.submit({text, asUser: true})` (queued until the session is idle; not awaited). **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3) |
| `deliver` | M2 **[M2]** **[fw1]** **[#229]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. `text` is submitted with `$.prompt.submit({text, asUser: true})` (queued until the session is idle; not awaited). **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the submission is made, so it is the next thing posted. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `<project>/<writer>`; a key naming no project (a CLI older than this) is held to the bare `<writer>` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none |

##### Events (the mod's posts; the stream's lines)

Expand All @@ -474,7 +477,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**.
| `model` | M2 **[M2]** | `model, effort` — the choice now in force (null: the session's own) |
| `compact` / `start` | M2 **[M2]** | `id` — the `compact` command's id |
| `compact` / `end` | M2 **[M2]** | `id`, and one of: `tokensBefore, tokensAfter` (null when the engine recorded none); `skip` (a hook vetoed it); `error` (the engine refused it, as during a turn) |
| `delivered` | M2 **[M2]** (was reserved) | `key: {writer, seq}, outcome: "submitted" \| "duplicate" \| "refused"`, `reason?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt |
| `delivered` | M2 **[M2]** (was reserved), **[#229]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused"`, `reason?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt |
| `kept` | M2 **[fw1]** **[fw2]** (the child's own) | `ok: false, error` when writing `<id>.json` starts to fail (said once, however many batches it keeps failing; retried at the end of every batch); `ok: true` when a write works again after a failure |

##### Subscription (`GET /events`)
Expand Down
52 changes: 52 additions & 0 deletions docs/doperpowers/specs/2026-10-13-smns-project-log-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,41 @@ terminal session the human opened, so reconciliation re-joins it only as
a different process (another pid, or a later start). A resume re-joins,
and the retire is not undone a moment later.

**Hand-off.** Sending an interactive session to the background
(`/background`, the agents view's ←) does not move its process: the
harness starts a job with `--resume <transcript> --fork-session` — a new
session id, a new process, a new bridge endpoint — lets the interactive
process exit, and appends `{"type": "continued-in", "continuedInSessionId":
<new>}` to the old transcript (its own `/resume` hides the old session by
it). The job is the same member: the old session is retired with `reason`
`continued` and `to` the new one, in its own file, and the projection
reads the new session as continuing it (`former`): what it has not set of
its own — alias, spawner, in-charge line, status, worktree — it carries on,
its chat cursor is copied, a seat that held the old session moves to it,
and a tag, `alias#short`, or full id naming the old session resolves to
it. The link is recorded when the job joins while the old process still
exits (the usual case: observed, it outlives the job's start by half a minute), else
when the old one's membership would end, and for a seat at every
reconciliation while the session it holds no longer runs (the harness
writes the record only once the job has started, so the job may join
first). The cursor is copied and the seat moved before the retirement is
recorded, so a hand-off cut short (a seat lock another command holds, a
hook out of time) leaves the old session joined and is done again whole.
The record is read as the harness reads it, the latest one with no turn
after it, so an old session resumed and worked in again is its own: it
re-joins, and a `joined` of its own after the `continued` retirement ends
the link, the job keeping what its own events hold. A `continued`
retirement holds against the exiting process as a `retire` does.

**Names the harness makes up.** The alias of a seat-less session is its
harness name, unless that name is one the harness made up (`nameSource`
`derived`: the directory's name and one random byte, drawn again by every
process; `auto`: a title it wrote; `collision`: a given name it suffixed)
and the log already holds an alias for the member. A resume therefore
keeps `maws-4d` rather than becoming `maws-3f`, and a job keeps the alias
of the session it continues rather than its generated title. A name
someone gives (`/rename`, `--name`) renames the member as before.

There is no host seat. A seat that spawns another is recorded as its
spawner (§6) and nothing more: it does not integrate the other's work, is
not woken by its reports, and retiring it leaves the other running. The
Expand Down Expand Up @@ -1789,6 +1824,23 @@ Dispatch scripts: one `--brief` each.
and the hub verifies any kind.
Date/Author: 2026-10-17, the desk's session-title change.

- Decision: a session sent to the background is the member it was (§2,
Hand-off), linked by the harness's own `continued-in` record and carried
in the log as a `retired` with `reason` `continued` and `to`; and a name
the harness made up never replaces an alias the log holds. The owner saw
a session's alias and socket change on every `/background` (MAWS,
2026-10-17: `maws-fe` became "GUI AI agent workspace charter", a new
session, and the old one was retired `ended`), and a resume rename a
member (`maws-4d` → `maws-3f`, same session). Rejected: a member id
apart from the session id, which would re-key every record sminos and
the desk keep per session for a case the projection can fold; and a
stable socket path per member, which would let one endpoint answer for
two processes while both run. A retirement, not a new kind,
so an older reader (the desk's projection) reads the old session as
retired and the job as a member of its own, which is what it reads now.
The desk folding the two is MAWS's follow-up.
Date/Author: 2026-10-17, the hand-off change.

## Outcomes & Retrospective

2026-10-14, at the end of execution and before the branch review.
Expand Down
13 changes: 10 additions & 3 deletions hooks/mods/bridge-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ export type TranscriptRecord = { type: 'assistant' | 'user'; uuid: string; messa
export type Check = { toolUseId: string; tool: string; agentId: string | null; input: unknown }
/** One agent of the session as `$.agent.list()` gives it (the fields the bridge reads). */
export type ListedAgent = { id: string; description: string; type: string; status: string; parentId?: string; name?: string }
/** A log event's key: its project (absent from a CLI older than the project in the key), writer and seq. */
export type DeliveryKey = { project?: string; writer: string; seq: number }
/** A log event submitted as the human's words, whose row is still to be kept. */
export type Submission = { key: { writer: string; seq: number }; text: string }
export type Submission = { key: DeliveryKey; text: string }
/** A delivery the engine dropped after it was submitted, as `delivered {outcome: "refused"}` reports it. */
export type Refusal = { key: { writer: string; seq: number }; reason: string }
export type Refusal = { key: DeliveryKey; reason: string }

export type BridgeState = {
/**
Expand Down Expand Up @@ -158,7 +160,12 @@ export type BridgeState = {
toolCalls: number
/** The model and effort chosen through the bridge, if any. */
override?: Override
/** Per writer, the highest seq of a log event submitted: delivery goes in seq order, so any key at or below it was. */
/**
* Per mark, the highest seq of a log event submitted: delivery goes in seq
* order, so any key at or below it was. A mark is `<project>/<writer>`, or
* the bare writer for a key naming no project; kept marks are taken as
* they come, whichever they are.
*/
delivered: Map<string, number>
/**
* Whether the module's first hello was read: what the child keeps
Expand Down
8 changes: 4 additions & 4 deletions hooks/mods/bridge-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function track(e: BridgeEvent) {

/** The refusals a post the child answered carried, in its own events or a resync's: the child keeps them. */
function acknowledge(batch: BridgeEvent[]) {
const name = (k: Refusal['key']) => `${k.writer}:${k.seq}`
const name = (k: Refusal['key']) => `${k.project ?? ''}|${k.writer}:${k.seq}`
const carried = new Set(
batch.flatMap((e) =>
e.type === 'resync' ? (e.refused as Refusal[]).map((r) => name(r.key)) : e.type === 'delivered' && e.outcome === 'refused' ? [name(e.key as Refusal['key'])] : [],
Expand Down Expand Up @@ -235,9 +235,9 @@ export function adoptKept(kept: unknown) {
state.override = next.model === null && next.effort === null ? undefined : next
}
if (typeof delivered === 'object' && delivered !== null) {
for (const [writer, seq] of Object.entries(delivered)) {
if (Number.isInteger(seq) && (seq as number) > (state.delivered.get(writer) ?? 0)) {
state.delivered.set(writer, seq as number)
for (const [mark, seq] of Object.entries(delivered)) {
if (Number.isInteger(seq) && (seq as number) > (state.delivered.get(mark) ?? 0)) {
state.delivered.set(mark, seq as number)
}
}
}
Expand Down
Loading
Loading