watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.
command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).
armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.
the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.
so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.
docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.
bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+42
-15
@@ -335,23 +335,50 @@ only Command HQ, so it is not optional groundwork the way most of this document
|
||||
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
|
||||
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
|
||||
|
||||
**Not yet built — the harder half:**
|
||||
**Built, item 4 (ahead of item 3 — plumbing needed to exist before the trigger could be tested):**
|
||||
|
||||
- **Harness-owned re-arm.** The proposal's actual infrastructure ask: once the agent's turn responding to a
|
||||
firing ends, the PLATFORM (not the model) automatically respawns the original watch command as a fresh
|
||||
background task. The natural implementation reuses `task-restart.ts`'s existing `commandForTask` (already
|
||||
reconstructs a shell command from a transcript's tool_use/tool_result pairing) and its detached-spawn
|
||||
machinery — re-arming becomes "run the restart path again, automatically, keyed off a `result` event for
|
||||
a commander whose watcher just fired," rather than new command-recovery logic.
|
||||
- **Arming state on the commander row.** `armed`, the current watch task's id, `lastFiredAt` — the
|
||||
proposal's own suggested order puts this LAST, "once 3 exists to write it." Settles "one armed watcher
|
||||
per commander" as a natural side effect (the row can refuse a second) and "stopped vs died" for commanders
|
||||
specifically (the thing that re-arms already knows which it was).
|
||||
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
|
||||
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
|
||||
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
|
||||
restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and accepts the same known
|
||||
gap `running` already lives with — see the schema file's docstring.
|
||||
`armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` in `queries.ts`; exposed
|
||||
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
|
||||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||||
running turn has).
|
||||
- **`armWatcher` threaded end-to-end, client to server, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
|
||||
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
|
||||
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
|
||||
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
|
||||
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
|
||||
|
||||
Suggested order from the proposal, followed here: (1) prompt + dialog — done, (2) stopped-vs-died — done,
|
||||
(3) harness-owned re-arm, (4) arming state. "Nothing here blocks the first live test of the apps; all of it
|
||||
blocks trusting a watcher that is meant to run for months" — the proposal's own words, and the reason (3)
|
||||
and (4) are still open rather than rushed.
|
||||
**Not yet built — item 3, and a real design question found while trying to build it:**
|
||||
|
||||
The original plan here was wrong and is worth recording as wrong rather than quietly fixed: `task-restart.ts`'s
|
||||
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
|
||||
respawns it detached. **It is not reusable for this.** A restarted job is deliberately outside the SDK's
|
||||
own `Monitor` tracking — `chat.ts`'s restart route emits its own `task:notification` via `emitToSession`
|
||||
specifically because the harness stopped watching. That path updates the UI; it does not reach back into
|
||||
the agent's own context, because there is no mechanism by which it could. A `handoff` watcher's whole
|
||||
point is the OPPOSITE: the agent must be told again when it fires again, which only happens through the
|
||||
SDK's own notification delivery, which only exists for a job the SDK itself is tracking.
|
||||
|
||||
So harness-owned re-arm cannot be a silent platform-side respawn. It has to drive the AGENT to reissue its
|
||||
own `run_in_background`/`Monitor` call — the platform's job is to make that reissue AUTOMATIC and
|
||||
CORRECT (the right command, the right moment, no double-arming) rather than something the model has to
|
||||
remember across turns, not to do the reissuing itself. Concretely, likely shape: once a fired watcher's
|
||||
"acting" turn ends (`result`, for a commander whose `watcherTaskId` just produced a `task:notification`),
|
||||
the platform sends a SYNTHETIC follow-up turn — not from the user — instructing the agent to re-arm using
|
||||
the exact command `commandForTask` recovers from the original transcript. `commandForTask` is still the
|
||||
right reuse; `restartBackgroundTask` is not. Not implemented — raised with the owner before writing it, for
|
||||
the second time tonight, because getting the mechanism itself wrong is more expensive to discover after
|
||||
the fact than before.
|
||||
|
||||
Suggested order from the proposal: (1) prompt + dialog — done, (2) stopped-vs-died — done, (4) arming
|
||||
state and plumbing — done ahead of schedule, (3) the actual re-arm trigger — open, blocked on confirming
|
||||
the synthetic-turn design above. "Nothing here blocks the first live test of the apps; all of it blocks
|
||||
trusting a watcher that is meant to run for months" — the proposal's own words, and the reason this is
|
||||
still open rather than rushed.
|
||||
|
||||
## Later — explicitly deferred, not forgotten
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ export {
|
||||
setCommandHqSessionCategory,
|
||||
deleteCommandHqSession,
|
||||
purgeCommandHqSessions,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
listCommandHqTags,
|
||||
createCommandHqTag,
|
||||
deleteCommandHqTag,
|
||||
|
||||
@@ -44,7 +44,26 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
|
||||
// ── Commanders (command_hq_sessions) ──
|
||||
|
||||
export type CommandHqSessionRow = { id: number; sessionId: string; categoryId: number | null; createdAt: Date };
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
categoryId: number | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
watcherLastFiredAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/** One definition of the row shape, reused everywhere it is selected — see the schema file for what each field means. */
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
watcherLastFiredAt: commandHqSessions.watcherLastFiredAt,
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
/**
|
||||
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
|
||||
@@ -60,22 +79,12 @@ export async function createCommandHqSession(
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId })
|
||||
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
|
||||
.returning({
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
});
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
|
||||
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
|
||||
if (row) return row;
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
})
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
|
||||
return existing!;
|
||||
@@ -96,16 +105,7 @@ export async function listCommandHqSessions(
|
||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||
: and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.categoryId, categoryId));
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
})
|
||||
.from(commandHqSessions)
|
||||
.where(where)
|
||||
.orderBy(commandHqSessions.createdAt);
|
||||
return db.select(SESSION_ROW_COLUMNS).from(commandHqSessions).where(where).orderBy(commandHqSessions.createdAt);
|
||||
}
|
||||
|
||||
// drizzle's `eq` cannot express `IS NULL`.
|
||||
@@ -126,17 +126,54 @@ export async function listCommandHqSessionIds(userId: number): Promise<Set<strin
|
||||
|
||||
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
})
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm (or re-arm) a commander's watcher — called both the first time a `handoff` job starts and every
|
||||
* time the harness re-arms it after acting on a fire. Idempotent in effect: arming with the same task id
|
||||
* twice just writes the same values again.
|
||||
*/
|
||||
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ watcherArmed: true, watcherTaskId: taskId })
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The watcher fired. Records WHEN for display — does not touch `watcherArmed` or `watcherTaskId`, which
|
||||
* stay exactly as they were through the fired-then-acting window, right up until the next `armCommandHqWatcher`
|
||||
* replaces the task id on re-arm.
|
||||
*/
|
||||
export async function recordCommandHqWatcherFired(userId: number, sessionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ watcherLastFiredAt: new Date() })
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disarm a commander's watcher. No product path calls this yet — there is no UI for it — but the
|
||||
* mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be cleared
|
||||
* alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
|
||||
*/
|
||||
export async function disarmCommandHqWatcher(userId: number, sessionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ watcherArmed: false, watcherTaskId: null })
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Move a commander to a category, or `null` to uncategorise it explicitly (distinct from never having had one). */
|
||||
export async function setCommandHqSessionCategory(
|
||||
userId: number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { boolean, integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
@@ -12,11 +12,26 @@ import { users } from '../auth/schema';
|
||||
* ONLY place a commander's name lives. A `name` column here would be a second place the same fact could be
|
||||
* written, and the day it disagreed with the transcript there would be no way to say which was right.
|
||||
*
|
||||
* No `status` column. Whether a commander is running is derived the same way the background-job tray already
|
||||
* derives it — folding `chat_session_events` for the session (`task:started` minus `task:notification`,
|
||||
* `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a second source of truth that
|
||||
* goes stale the moment the harness sidecar restarts without emitting a final `task:notification`, and
|
||||
* nothing would ever correct it.
|
||||
* Still no `status` column. Whether a commander is CURRENTLY RUNNING is derived the same way the
|
||||
* background-job tray already derives it — folding `chat_session_events` (`task:started` minus
|
||||
* `task:notification`, `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a
|
||||
* second source of truth that goes stale the moment the harness sidecar restarts without emitting a final
|
||||
* `task:notification`, and nothing would ever correct it.
|
||||
*
|
||||
* ── `armed`, below, is a different kind of fact and is allowed to be stored ──
|
||||
*
|
||||
* `watcherArmed` does not answer "is a process alive right now" — it answers "should the platform keep
|
||||
* re-arming this watcher on every fire", which is an INTENT the owner set, not an external process state
|
||||
* the platform is merely reporting. Nothing outside this table can silently flip it the way a sidecar
|
||||
* restart silently ends a process, so it does not have the staleness problem `status` was rejected for.
|
||||
* See `COMMS/command-hq/watcher-loop-proposal.md` and `docs/command-hq.md` → "The watcher loop".
|
||||
*
|
||||
* `watcherTaskId` is closer to the rejected shape — it names a specific running (or just-fired) task, and
|
||||
* IS a live-process fact. Kept anyway, deliberately, because there is no way to re-arm without knowing
|
||||
* which task just fired: it is the join key between "a background job ended" and "which commander's
|
||||
* watcher that was", and the alternative (scanning every commander's transcript on every task:notification
|
||||
* to guess) is worse than accepting that a sidecar restart can strand it — which is the SAME documented gap
|
||||
* `running` already lives with, not a new one. See "Later" in `docs/command-hq.md`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -63,6 +78,18 @@ export const commandHqSessions = pgTable(
|
||||
sessionId: text('session_id').notNull(),
|
||||
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
|
||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
||||
/**
|
||||
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
||||
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
||||
* one this commander is waiting on" and to recover its original command via `commandForTask` when
|
||||
* re-arming. Null whenever `watcherArmed` is false; not cleared BETWEEN a fire and its re-arm, which is
|
||||
* exactly the window a card would show as "armed, acting on what just fired" rather than "watching".
|
||||
*/
|
||||
watcherTaskId: text('watcher_task_id'),
|
||||
/** When the watcher last fired. Display only — "Armed, last fired 3h ago". Never read for logic. */
|
||||
watcherLastFiredAt: timestamp('watcher_last_fired_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
|
||||
|
||||
@@ -76,6 +76,13 @@ export type ClientMessage =
|
||||
* first turn is the first moment the preference can be acted on OR written down.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
* other caller omits it. The server correlates it to the NEXT `task:started` this turn produces, so
|
||||
* it must ride the exact message that starts the watching turn.
|
||||
*/
|
||||
armWatcher?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'resume';
|
||||
|
||||
@@ -601,6 +601,7 @@ async function handleChat(
|
||||
contextId?: string;
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
armWatcher?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { userId } = ws.data;
|
||||
@@ -723,6 +724,7 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
|
||||
@@ -893,6 +895,7 @@ async function handleClaudeCodeChat(
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
armWatcher?: boolean;
|
||||
},
|
||||
effectivePrompt: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
disarmCommandHqWatcher,
|
||||
listCommandHqSessionTags,
|
||||
listCommandHqTags,
|
||||
createCommandHqTag,
|
||||
@@ -147,6 +148,14 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
|
||||
*/
|
||||
running: boolean;
|
||||
/**
|
||||
* Whether the platform is keeping this commander's watcher re-armed — see `docs/command-hq.md` → "The
|
||||
* watcher loop". Unlike `running`, this IS stored: it is the owner's own intent, not a live-process fact
|
||||
* the platform is reporting on. `watcherTaskId` itself is not exposed — an internal correlation id with
|
||||
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
|
||||
*/
|
||||
watcherArmed: boolean;
|
||||
watcherLastFiredAt: Date | null;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -189,6 +198,8 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
@@ -299,6 +310,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
});
|
||||
@@ -337,18 +350,22 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
/**
|
||||
* PATCH /command-hq/commanders/:sessionId
|
||||
*
|
||||
* Body may carry either or both:
|
||||
* Body may carry any of:
|
||||
* `categoryId: number | null` — move to a category, or `null` to uncategorise
|
||||
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
|
||||
* rename action calls — there is no separate name field on this table to disagree with it.
|
||||
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
|
||||
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
|
||||
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
|
||||
* this route is not it.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const body = await ctx.req
|
||||
.json<{ categoryId?: number | null; name?: string }>()
|
||||
.catch(() => ({}) as { categoryId?: number | null; name?: string });
|
||||
.json<{ categoryId?: number | null; name?: string; watcherArmed?: false }>()
|
||||
.catch(() => ({}) as { categoryId?: number | null; name?: string; watcherArmed?: false });
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
@@ -360,6 +377,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
|
||||
if (!ok) throw errors.NOT_FOUND('transcript not found');
|
||||
}
|
||||
if (body.watcherArmed === false) {
|
||||
await disarmCommandHqWatcher(user.id, sessionId);
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export type QueuedPrompt = {
|
||||
hiddenPrefix?: string;
|
||||
ids: string[];
|
||||
images: { filename: string; dataUrl: string }[];
|
||||
/** This item is arming a Command HQ watcher. See `sendPrompt`'s param of the same name. */
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
|
||||
type UseEmbeddableChatParams = {
|
||||
@@ -233,6 +235,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
if (hidden) prompt = `${hidden}\n\n${prompt}`;
|
||||
if (prefix) prompt = `${prefix}${prompt}`;
|
||||
|
||||
// True if ANY queued item asked to arm a watcher — realistically always exactly one item, since the
|
||||
// dialog delivers alone or queues alone, but a batch is a batch and this reads the same as `hidden`
|
||||
// joining above: whichever items contributed, contribute fully.
|
||||
const armWatcher = items.some((item) => item.armWatcher);
|
||||
|
||||
// Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory
|
||||
// system prompt each turn, so it needs the cwd every time. It stays constant for a session.
|
||||
sendPrompt(
|
||||
@@ -245,6 +252,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
// The display text. Given either kind of hidden instruction, the bubble shows only what the
|
||||
// user actually wrote.
|
||||
promptPrefix || hidden ? text : undefined,
|
||||
armWatcher || undefined,
|
||||
);
|
||||
userScrolledRef.current = false;
|
||||
return true;
|
||||
@@ -293,7 +301,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
*
|
||||
* Queues behind a running turn like anything else, rather than being dropped or racing it.
|
||||
*/
|
||||
const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string) => {
|
||||
const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string, armWatcher?: boolean) => {
|
||||
const body = text.trim();
|
||||
if (!body) return;
|
||||
const item: QueuedPrompt = {
|
||||
@@ -303,6 +311,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
hiddenPrefix,
|
||||
ids: [],
|
||||
images: [],
|
||||
armWatcher,
|
||||
};
|
||||
if (isGenerating) {
|
||||
setQueue([...queuedRef.current, item]);
|
||||
|
||||
@@ -32,7 +32,8 @@ type BackgroundJobDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cwd: string;
|
||||
onSubmit: (intent: string, preamble: string) => void;
|
||||
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
||||
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
||||
/**
|
||||
* Is this composer running inside a Command HQ commander?
|
||||
*
|
||||
@@ -101,7 +102,11 @@ export const BackgroundJobDialog = ({
|
||||
cwd,
|
||||
};
|
||||
// The intent is what the transcript shows; the preamble rides underneath it.
|
||||
onSubmit(watching ? condition.trim() : body.trim(), buildBackgroundJobPrompt(spec));
|
||||
onSubmit(
|
||||
watching ? condition.trim() : body.trim(),
|
||||
buildBackgroundJobPrompt(spec),
|
||||
watching && onFire === 'handoff',
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -173,11 +173,11 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
open={jobDialogOpen}
|
||||
onOpenChange={setJobDialogOpen}
|
||||
cwd={cwd}
|
||||
onSubmit={(intent, preamble) => {
|
||||
onSubmit={(intent, preamble, armWatcher) => {
|
||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||
// background jobs an agent starts on its own.
|
||||
armBackgroundJob();
|
||||
void sendWithHiddenPrefix(intent, preamble);
|
||||
void sendWithHiddenPrefix(intent, preamble, armWatcher);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -683,6 +683,13 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
groupSlug?: string | null,
|
||||
thinking?: string | null,
|
||||
displayText?: string,
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Only ever
|
||||
* true from `BackgroundJobDialog` submitting a `handoff` job inside a commander; every other caller
|
||||
* omits it. The server correlates it to the next `task:started` in THIS turn, so it must ride the
|
||||
* exact message that starts the watching turn, not a later one.
|
||||
*/
|
||||
armWatcher?: boolean,
|
||||
) {
|
||||
// Mark session as started on first message
|
||||
if (!hasStarted) {
|
||||
@@ -737,6 +744,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
...(armWatcher ? { armWatcher } : {}),
|
||||
// Always sent, never conditional. This is the ONLY channel that can put a brand-new conversation into
|
||||
// plan mode — there is no live session to control and no stored row to update before the first turn —
|
||||
// and sending it only when true would mean a session could never be turned back off this way either.
|
||||
|
||||
Reference in New Issue
Block a user