zigmini 1.0.0-beta.3 → 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.ko.md +27 -2
  2. package/README.md +27 -2
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
@@ -0,0 +1,20 @@
1
+ import { writeSync } from "node:fs";
2
+
3
+ /** The named process-stop boundaries of the Q-10 restart contract (test/contract/agent-collaboration-gaps-restart.contract.test.ts). */
4
+ export type TestCrashPoint =
5
+ | "collaboration.admission.before-commit"
6
+ | "collaboration.admission.after-commit"
7
+ | "collaboration.input.rpc-written"
8
+ | "collaboration.result.committed";
9
+
10
+ /**
11
+ * Test-only crash seam. Production never sets ZGW_TEST_CRASH_AT, so this returns at once there. When a restart contract
12
+ * test sets it to one point, the gateway writes `ZGW_TEST_CRASH_POINT <point>` synchronously to stderr and stops itself
13
+ * (SIGSTOP) at that exact boundary; the test then SIGKILLs the stopped process. Nothing runs between the boundary and the
14
+ * kill, so a pending transaction stays uncommitted and no later callback or socket frame is processed.
15
+ */
16
+ export function testCrashPoint(point: TestCrashPoint): void {
17
+ if (process.env.ZGW_TEST_CRASH_AT !== point) return;
18
+ writeSync(2, `ZGW_TEST_CRASH_POINT ${point}\n`);
19
+ process.kill(process.pid, "SIGSTOP");
20
+ }
package/src/work-watch.ts CHANGED
@@ -116,6 +116,31 @@ const NOTE_QUIET_IN_TOOL: Text = { en: "No activity for over {duration} while a
116
116
  const NOTE_CANCEL_STOPPED: Text = { en: "The run stopped after the cancel command", ko: "취소 명령 뒤 실행이 멈췄습니다" };
117
117
  const NOTE_UNDELIVERED: Text = { en: "The command was not confirmed as received", ko: "명령 수신을 확인하지 못했습니다" };
118
118
  const NOTE_DELIVERY_CHECKING: Text = { en: "Confirming that the command was received", ko: "명령 수신을 확인하는 중입니다" };
119
+ const NOTE_ADMISSION_FAILED: Text = { en: "The command could not be delivered to the work session", ko: "명령을 작업 세션에 전달하지 못했습니다" };
120
+ /** Closures by housekeeping reconcile (bug 282): the stored reason says why, the note says it on the card. */
121
+ const RECONCILE_NOTES: Readonly<Record<string, Text>> = {
122
+ reconcile_session_closed: { en: "Closed because the work session ended without a report", ko: "작업 세션이 보고 없이 닫혀 실패로 정리했습니다" },
123
+ reconcile_cancel_unreachable: { en: "Cancelled because the cancel command could not reach a work that was not running", ko: "실행 중이 아닌 작업에 취소 명령이 닿지 못해 취소로 정리했습니다" },
124
+ };
125
+ /** Confirm alerts from housekeeping reconcile (bug 282): main reads the summary and asks the operator; the stored reason stays the key. */
126
+ const CONFIRM_TAIL: Text = {
127
+ en: "Ask the operator whether to cancel this work or send a new instruction. If the new instruction returns prior_command_unknown, send the cancel first.",
128
+ ko: "운영자에게 이 작업을 취소할지, 새 지시를 보낼지 물어봐 주세요. 새 지시가 prior_command_unknown으로 거부되면 취소를 먼저 보내 주세요.",
129
+ };
130
+ export const RECONCILE_CONFIRM_SUMMARIES: Readonly<Record<string, Text>> = {
131
+ reconcile_confirm_undelivered: {
132
+ en: `The last command was not confirmed as received by the work session. ${CONFIRM_TAIL.en}`,
133
+ ko: `마지막 명령을 작업 세션이 받았는지 확인하지 못했습니다. ${CONFIRM_TAIL.ko}`,
134
+ },
135
+ reconcile_confirm_no_report: {
136
+ en: `The work session stopped after receiving the command, but it sent no report. ${CONFIRM_TAIL.en}`,
137
+ ko: `작업 세션이 명령을 받은 뒤 보고 없이 멈췄습니다. ${CONFIRM_TAIL.ko}`,
138
+ },
139
+ reconcile_confirm_unreachable: {
140
+ en: `The last command could not reach the work session. ${CONFIRM_TAIL.en}`,
141
+ ko: `마지막 명령이 작업 세션에 닿지 못했습니다. ${CONFIRM_TAIL.ko}`,
142
+ },
143
+ };
119
144
  const NOTE_REPORT: Text = { en: "Report: {summary}", ko: "보고: {summary}" };
120
145
  const NOTE_REQUEST_UNANSWERED: Text = { en: "The status report request got no response", ko: "상태 보고 요청에 응답이 없습니다" };
121
146
  const NOTE_REQUESTED: Text = { en: "Stopped without a report, so a status report was requested", ko: "보고 없이 멈춰 상태 보고를 요청했습니다" };
@@ -164,7 +189,10 @@ function storeState(work: WorkItem): WatchState {
164
189
  export function judge(input: JudgeInput, config: WatchConfig = DEFAULT_WATCH): Verdict {
165
190
  const { work, commands, updates, live, now } = input;
166
191
  const base = Date.parse(work.updatedAt);
167
- if (TERMINAL.has(work.status)) return { state: work.status as WatchState, reason: "reported", since: base, next: Infinity };
192
+ if (TERMINAL.has(work.status)) {
193
+ const reconciled = work.statusReason?.startsWith("reconcile_") ? RECONCILE_NOTES[work.statusReason] : undefined;
194
+ return { state: work.status as WatchState, reason: "reported", ...(reconciled ? { note: say(reconciled) } : {}), since: base, next: Infinity };
195
+ }
168
196
  // While OMO is away nothing new can be observed: show the record, judge nothing.
169
197
  if (!input.ready) return { state: storeState(work), reason: "omo_not_ready", since: base, next: Infinity };
170
198
  if (input.openDecisions > 0 || work.status === "waiting_input") {
@@ -183,6 +211,11 @@ export function judge(input: JudgeInput, config: WatchConfig = DEFAULT_WATCH): V
183
211
  state: "waiting", waitingFor: "children", reason: "children_running",
184
212
  note: say(NOTE_CHILDREN, { count: children }), since: waitSince, next,
185
213
  });
214
+ // Bug 282: an admission failure leaves the command pending with its error and nothing retries it, so it is not
215
+ // being sent; this holds with or without runtime evidence (right after a restart there is none).
216
+ if (command.delivery === "pending" && command.error !== null) {
217
+ return { state: "unknown", reason: "command_admission_failed", note: say(NOTE_ADMISSION_FAILED), since: base, next: Infinity };
218
+ }
186
219
  // Without confirmed runtime evidence (no event, no reconciliation yet) the watcher shows the record and never acts.
187
220
  if (live.streaming === null) return { state: storeState(work), reason: "runtime_unknown", since: base, next: Infinity };
188
221
 
@@ -307,6 +340,11 @@ export class WorkWatcher {
307
340
  return live;
308
341
  }
309
342
 
343
+ /** Active child tasks of a work from its live signals; null when none were seen since OMO came back (unknown). */
344
+ children(workId: string): number | null {
345
+ return this.lives.get(workId)?.children?.active ?? null;
346
+ }
347
+
310
348
  /** OMO is away: forget every live signal; judge and act on nothing. */
311
349
  pause(): void {
312
350
  if (this.stopped) return;
@@ -412,18 +450,28 @@ export class WorkWatcher {
412
450
  if (event.success !== false || event.finalError === "Retry cancelled") return false;
413
451
  live.lastError = { kind: "retry_exhausted", message: typeof event.finalError === "string" ? event.finalError : say(ERROR_LABELS.retry_exhausted), at: now };
414
452
  return true;
415
- case "extension_event": {
453
+ case "extension_event":
416
454
  if (event.name !== "omo.task.updated" || !record(event.data) || !Array.isArray(event.data.tasks)) return false;
417
- const active = event.data.tasks.filter((task) => record(task) && (task.status === "pending" || task.status === "running")).length;
418
- const before = live.children?.active;
419
- live.children = { active, at: now };
420
- return before !== active;
421
- }
455
+ return this.setChildren(live, event.data.tasks, now);
422
456
  default:
423
457
  return false;
424
458
  }
425
459
  }
426
460
 
461
+ /** Counts the running sub-tasks of a task list; true when the count changed. */
462
+ private setChildren(live: Live, tasks: readonly unknown[], now: number): boolean {
463
+ const active = tasks.filter((task) => record(task) && (task.status === "pending" || task.status === "running")).length;
464
+ const before = live.children?.active;
465
+ live.children = { active, at: now };
466
+ return before !== active;
467
+ }
468
+
469
+ /** OMO's child-task list answered at a bind (bug 282, F3 F-1): sets the count like `omo.task.updated`, not activity. */
470
+ childTasks(workId: string, tasks: readonly unknown[]): void {
471
+ if (!this.observing) return;
472
+ if (this.setChildren(this.live(workId), tasks, this.clock())) this.schedule();
473
+ }
474
+
427
475
  /** A reconciliation read the native isStreaming. */
428
476
  runtime(workId: string, streaming: boolean): void {
429
477
  if (!this.observing) return;
package/src/work.ts CHANGED
@@ -5,6 +5,11 @@ import { OmoCommandError, type OmoClient, type OmoEvent, type OmoStatus } from "
5
5
  import { DecisionStoreError, Store, WorkStoreError, type LedgerState, type MainInput, type StoredEvent, type WorkCommand, type WorkItem, type WorkReport, type WorkUpdateKind } from "./store.ts";
6
6
  import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
7
7
  import { notificationHistory } from "./notification-history.ts";
8
+ import type { AuditLine, AuditSink } from "./housekeeping/audit.ts";
9
+ import { reconcileWorkId, type ReconcileItem } from "./housekeeping/collectors/reconcile-work.ts";
10
+ import { say } from "./server-text.ts";
11
+ import { RECONCILE_CONFIRM_SUMMARIES } from "./work-watch.ts";
12
+ import { classify, retryableRefusal, type ReconcileEvidence, type ReconcileVerdict } from "./housekeeping/reconcile/work.ts";
8
13
 
9
14
  const ACK_TIMEOUT_MS = 30_000;
10
15
  const PREFIX = "ZGW_WORK_COMMAND ";
@@ -15,7 +20,7 @@ type Session = { sessionId: string; sessionPath?: string; status: string };
15
20
  type State = {
16
21
  isStreaming?: boolean; steering?: string[]; followUp?: string[];
17
22
  ordered?: { text: string; mode?: string; clientInputId?: string }[];
18
- pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string;
23
+ pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string; sessionId?: string;
19
24
  };
20
25
  type Entry = NativeReplyEntry & { id: string; type: string; customType?: string; data?: unknown };
21
26
  export interface WorkSessionOwner {
@@ -25,6 +30,8 @@ export interface WorkSessionOwner {
25
30
  readonly sessionPath: string;
26
31
  }
27
32
  type Binding = { sessionId: string; generation: number; questions: Set<string>; owner: WorkSessionOwner };
33
+ /** The last get_state of a bound work session: native isStreaming, when it was read, and which commands it showed. */
34
+ type StateRead = { isStreaming: boolean; stateReadAt: string; matched: ReadonlySet<string> };
28
35
  export interface WorkRequest {
29
36
  operation: "start" | "send" | "list" | "get" | "cancel" | "report" | "publish" | "precomment";
30
37
  requestKey: string;
@@ -44,6 +51,8 @@ export interface WorkRouterOptions {
44
51
  /** Synchronous: release this exact old binding; never infer an owner from a later event. */
45
52
  onUnbound?(owner: WorkSessionOwner, reason: "parked" | "closed" | "disconnected" | "stopped"): void;
46
53
  onUiEvent?(owner: WorkSessionOwner, event: OmoEvent): void;
54
+ /** Bug 236: OMO queued an instruction (not a watcher status request) behind the work's running turn. */
55
+ onInstructionQueued?(owner: WorkSessionOwner): void;
47
56
  onDecisionsChanged?(): void;
48
57
  onSettled?(owner: WorkSessionOwner): void;
49
58
  /** Every event of a currently bound work session, for the work watcher's live signals. */
@@ -54,6 +63,14 @@ export interface WorkRouterOptions {
54
63
  mainLedger?(conv: string, sessionId: string): Promise<LedgerState | null>;
55
64
  /** Production uses the locked 30s bound. Tests may exercise the timeout at a shorter bound. */
56
65
  ackTimeoutMs?: number;
66
+ /** Active child tasks of a work, from the watcher's live signals; null or absent = unknown, which never allows a closure. */
67
+ children?(workId: string): number | null;
68
+ /** OMO's child-task list of a work session (`omo.task.list`, the `omo.task.updated` tasks), asked at every bind. */
69
+ onChildTasks?(workId: string, tasks: readonly unknown[]): void;
70
+ /** Housekeeping audit log: one line per reconcile closure (or refused closure) and per new confirm alert. */
71
+ audit?: AuditSink;
72
+ /** The reconcile items changed; housekeeping rescans its reconcile-work collector. */
73
+ onReconciled?(): void;
57
74
  }
58
75
  class RoutingError extends Error {
59
76
  constructor(readonly code: string, readonly status = 409) { super(code); }
@@ -114,6 +131,12 @@ export class WorkRouter {
114
131
  private readonly reconciliations = new Map<string, Promise<void>>();
115
132
  private readonly scheduled = new Set<string>();
116
133
  private readonly pendingUi = new Map<string, OmoEvent[]>();
134
+ // Reconcile evidence of the current OMO generation (bug 282); cleared whenever the generation changes.
135
+ private readonly refused = new Map<string, { error: string; retryable: boolean }>();
136
+ private readonly admissionFailed = new Map<string, Set<string>>();
137
+ private readonly lifecycle = new Map<string, { kind: "closed"; reason: string; at: string }>();
138
+ private readonly lastState = new Map<string, StateRead>();
139
+ private readonly reconciled = new Map<string, ReconcileItem>();
117
140
  private wholeReads: Promise<unknown> = Promise.resolve();
118
141
  private generation = 0;
119
142
  private stopped = false;
@@ -151,6 +174,8 @@ export class WorkRouter {
151
174
  const binding = this.bindings.get(workId);
152
175
  if (!binding) return;
153
176
  this.bindings.delete(workId);
177
+ // A parked or closed session's last state is not evidence about the work any more.
178
+ this.lastState.delete(workId);
154
179
  this.pendingUi.delete(binding.sessionId);
155
180
  this.options.onUnbound?.(binding.owner, reason);
156
181
  }
@@ -402,7 +427,11 @@ export class WorkRouter {
402
427
  if (!this.ready) return;
403
428
  const previous = this.flights.get(workId);
404
429
  if (previous) return previous;
405
- const flight = this.transmit(workId).finally(() => { if (this.flights.get(workId) === flight) this.flights.delete(workId); });
430
+ const generation = this.generation;
431
+ // T1: reconcile runs once the flight is gone, so it sees this generation's settled dispatch.
432
+ const flight: Promise<void> = this.transmit(workId)
433
+ .finally(() => { if (this.flights.get(workId) === flight) this.flights.delete(workId); })
434
+ .finally(() => this.reconcileAfterDispatch(workId, generation));
406
435
  this.flights.set(workId, flight);
407
436
  return flight;
408
437
  }
@@ -420,6 +449,11 @@ export class WorkRouter {
420
449
  try { binding = await this.ensure(this.work(workId)); }
421
450
  catch (error) {
422
451
  if (!this.stopped) {
452
+ if (generation === this.generation) {
453
+ this.refuse(workId, error);
454
+ const failed = this.admissionFailed.get(workId) ?? new Set<string>();
455
+ this.admissionFailed.set(workId, failed.add(command.id));
456
+ }
423
457
  this.store.setWorkCommandDelivery(command.id, "pending", { error: errorText(error) });
424
458
  this.notify(workId);
425
459
  }
@@ -445,6 +479,9 @@ export class WorkRouter {
445
479
  this.store.setWorkCommandDelivery(command.id, "accepted", { disposition: interrupt ? "turn_aborted_cleanup_unconfirmed" : typeof data?.disposition === "string" ? data.disposition : "prompt_ack" });
446
480
  this.notify(workId);
447
481
  if (!interrupt && data?.disposition === "queued" && this.current(workId, binding)) {
482
+ if (command.kind === "instruct" && !(record(command.payload) && command.payload.watcher === "status_request")) {
483
+ this.options.onInstructionQueued?.(binding.owner);
484
+ }
448
485
  void this.omo.command<State>("get_state", { sessionId: binding.sessionId })
449
486
  .then((state) => { if (this.current(workId, binding)) this.resumeQueuedCommand(workId, binding, state); })
450
487
  .catch((error) => this.fail(error));
@@ -492,10 +529,14 @@ export class WorkRouter {
492
529
  };
493
530
  const binding: Binding = { sessionId: opened.sessionId, generation, questions: new Set(), owner };
494
531
  this.bindings.set(work.id, binding);
532
+ this.refused.delete(work.id);
533
+ this.admissionFailed.delete(work.id);
534
+ this.lifecycle.delete(work.id);
495
535
  this.options.onBound?.(owner, opened.state?.pendingQuestions ?? []);
496
536
  const replay = this.pendingUi.get(opened.sessionId) ?? [];
497
537
  this.pendingUi.delete(opened.sessionId);
498
538
  for (const event of replay) this.onEvent(event);
539
+ this.learnChildren(work.id, binding, opened.state?.sessionId);
499
540
  return binding;
500
541
  }).finally(() => {
501
542
  if (this.opening.get(work.id) === promise) this.opening.delete(work.id);
@@ -505,6 +546,25 @@ export class WorkRouter {
505
546
  return promise;
506
547
  }
507
548
 
549
+ /**
550
+ * Bug 282 (F3 F-1): a bind starts with the child-task count unknown, and an idle work without sub-tasks sends no later
551
+ * `omo.task.updated` (its snapshot at session start arrives before the open ACK, while the session is unbound). So every
552
+ * bind asks OMO once, by the durable session id. An answer without a task list (an OMO without `omo.task.list`,
553
+ * not_found) or no answer leaves the count unknown, which keeps closures off. Known children re-run the T1 evaluation.
554
+ */
555
+ private learnChildren(workId: string, binding: Binding, durableSessionId: string | undefined): void {
556
+ if (typeof durableSessionId !== "string") return;
557
+ void this.omo.command<unknown>("extension_request", { sessionId: binding.sessionId, name: "omo.task.list", data: { parent_session_id: durableSessionId } })
558
+ .then((answer) => {
559
+ if (!this.current(workId, binding) || !record(answer) || !Array.isArray(answer.tasks)) return;
560
+ this.options.onChildTasks?.(workId, answer.tasks);
561
+ this.schedule(workId);
562
+ }, (error: unknown) => {
563
+ // A refusal (no such request on this OMO) is OMO's answer that it cannot tell: the count stays unknown.
564
+ if (!(error instanceof OmoCommandError)) this.fail(error);
565
+ });
566
+ }
567
+
508
568
  /** The one unbounded response this router asks for: one whole-session read in flight at a time, across works. */
509
569
  private wholeSession(sessionId: string, live: () => boolean): Promise<Entry[] | null> {
510
570
  const read = this.wholeReads.then(() => live()
@@ -605,6 +665,7 @@ export class WorkRouter {
605
665
  }),
606
666
  this.omo.command<State>("get_state", { sessionId: binding.sessionId }),
607
667
  ]);
668
+ const stateReadAt = new Date().toISOString();
608
669
  if (!entries || !this.current(workId, binding)) return;
609
670
  if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title }, entries, replyRevision);
610
671
  if (typeof state.isStreaming === "boolean") this.options.onRuntime?.(workId, state.isStreaming);
@@ -615,6 +676,10 @@ export class WorkRouter {
615
676
  }
616
677
  }
617
678
  const evidence = [...queue(state), ...entries.filter((e) => e.type === "message" && e.message?.role === "user").map((e) => messageText(e.message?.content))];
679
+ if (typeof state.isStreaming === "boolean") {
680
+ const matched = new Set(this.store.listWorkCommands(workId).filter((command) => evidence.some((value) => this.matches(value, command))).map((command) => command.id));
681
+ this.lastState.set(workId, { isStreaming: state.isStreaming, stateReadAt, matched });
682
+ }
618
683
  for (const command of this.store.listWorkCommands(workId)) {
619
684
  if (["pending", "sending", "unknown"].includes(command.delivery) && evidence.some((value) => this.matches(value, command))) this.store.setWorkCommandDelivery(command.id, "accepted", { disposition: "command_envelope" });
620
685
  }
@@ -700,12 +765,19 @@ export class WorkRouter {
700
765
  this.store.observeWork(workId, "unknown", "session_closed");
701
766
  this.notify(workId);
702
767
  }
768
+ // Parked (handoff_parked) is not failure, and a generation slot removal is not this session's loss.
769
+ if (event.type === "session_closed" && typeof event.reason === "string" && event.reason !== "handoff_parked" &&
770
+ event.reason !== "generation_ended" && event.synthetic !== true) {
771
+ this.lifecycle.set(workId, { kind: "closed", reason: event.reason, at: new Date().toISOString() });
772
+ }
773
+ this.schedule(workId); // T4
703
774
  }
704
775
  // senpi publishes agent_settled (reason session_closed) before it seals a session closed or
705
776
  // parked mid-turn; that session can no longer be read, and its lifecycle event follows.
706
777
  else if (event.type === "agent_settled" && event.reason !== "session_closed") {
707
778
  void this.reconcile(workId).then(() => {
708
779
  if (this.current(workId, binding)) this.options.onSettled?.(binding.owner);
780
+ this.schedule(workId); // T3
709
781
  }).catch((error) => this.fail(error));
710
782
  }
711
783
  return true;
@@ -720,6 +792,7 @@ export class WorkRouter {
720
792
  const attached = new Set(this.bindings.keys());
721
793
  this.generation++;
722
794
  for (const workId of [...this.bindings.keys()]) this.unbind(workId, "disconnected");
795
+ this.clearEvidence();
723
796
  this.opening.clear();
724
797
  this.pendingUi.clear();
725
798
  const affected: string[] = [];
@@ -768,7 +841,14 @@ export class WorkRouter {
768
841
  }
769
842
  continue;
770
843
  }
771
- await this.reconcile(work.id);
844
+ try {
845
+ await this.reconcile(work.id);
846
+ } catch (error) {
847
+ // T2: the refusal is reconcile evidence; dispatch still runs so T1 evaluates it.
848
+ if (generation === this.generation) this.refuse(work.id, error);
849
+ this.schedule(work.id);
850
+ throw error;
851
+ }
772
852
  this.schedule(work.id);
773
853
  } catch (error) { this.fail(error); }
774
854
  }
@@ -780,6 +860,115 @@ export class WorkRouter {
780
860
  this.stopped = true;
781
861
  this.generation++;
782
862
  for (const workId of [...this.bindings.keys()]) this.unbind(workId, "stopped");
863
+ this.clearEvidence();
783
864
  this.pendingUi.clear();
784
865
  }
866
+
867
+ /** The router's current reconcile closures and pending confirms, for the housekeeping reconcile-work collector. */
868
+ reconcileItems(): ReconcileItem[] {
869
+ return [...this.reconciled.values()].filter((item) => this.stillClosed(item) ||
870
+ (item.kind === "confirm" && !["completed", "failed", "cancelled"].includes(this.store.getWork(item.workId)?.status ?? "completed")));
871
+ }
872
+
873
+ /** A closure stays listed until the work is reopened (a new instruct) or otherwise leaves that closed state. */
874
+ private stillClosed(item: ReconcileItem): boolean {
875
+ return item.kind === "close" && this.store.getWork(item.workId)?.statusReason === item.reason;
876
+ }
877
+
878
+ private clearEvidence(): void {
879
+ this.refused.clear();
880
+ this.admissionFailed.clear();
881
+ this.lifecycle.clear();
882
+ this.lastState.clear();
883
+ }
884
+
885
+ private refuse(workId: string, error: unknown): void {
886
+ const typed = error instanceof OmoCommandError;
887
+ const data = typed && record(error.errorData) ? error.errorData : null;
888
+ const retryAfterMs = typeof data?.retry_after_ms === "number" ? data.retry_after_ms : null;
889
+ this.refused.set(workId, { error: errorText(error), retryable: retryableRefusal(typed ? error.code : null, retryAfterMs) });
890
+ }
891
+
892
+ /** Evidence for the classifier, exactly as the field comments of ReconcileEvidence define it. */
893
+ private collect(workId: string, generation: number): ReconcileEvidence | null {
894
+ const work = this.store.getWork(workId);
895
+ if (!work) return null;
896
+ const commands = this.store.listWorkCommands(workId);
897
+ const latest = commands.at(-1);
898
+ const state = this.lastState.get(workId);
899
+ const inSession = (id: string) => state ? state.matched.has(id) : null;
900
+ const reported = new Set(this.store.listWorkUpdates(workId)
901
+ .filter((u) => reportKinds.has(u.kind) && !(record(u.details) && u.details.source === "watcher")).map((u) => u.commandSeq));
902
+ const blocked = latest?.delivery === "pending" ? commands.filter((c) => c.seq < latest.seq && c.delivery === "unknown").at(-1) : undefined;
903
+ const refused = this.refused.get(workId);
904
+ return {
905
+ status: work.status,
906
+ latest: latest ? {
907
+ id: latest.id, seq: latest.seq, kind: latest.kind, delivery: latest.delivery, error: latest.error, createdAt: latest.createdAt, acceptedAt: latest.acceptedAt,
908
+ admissionFailed: this.admissionFailed.get(workId)?.has(latest.id) ?? false, inSession: inSession(latest.id), reported: reported.has(latest.seq),
909
+ } : null,
910
+ blockedBy: blocked ? { id: blocked.id, seq: blocked.seq, delivery: blocked.delivery, inSession: inSession(blocked.id) } : null,
911
+ omoReady: this.ready,
912
+ evaluatedAfterDispatch: generation === this.generation,
913
+ session: refused ? { kind: "refused", retryable: refused.retryable, error: refused.error }
914
+ : state ? { kind: "readable", isStreaming: state.isStreaming, stateReadAt: state.stateReadAt } : { kind: "unread" },
915
+ lifecycle: this.lifecycle.get(workId) ?? null,
916
+ children: this.options.children?.(workId) ?? null,
917
+ openDecisions: this.store.unresolvedWorkDecisions(workId),
918
+ inFlight: this.flights.has(workId),
919
+ };
920
+ }
921
+
922
+ /** T1. Never sends a command; a cancel that is not accepted gets one fresh read first. */
923
+ private async reconcileAfterDispatch(workId: string, generation: number): Promise<void> {
924
+ try {
925
+ if (!this.ready || generation !== this.generation) return;
926
+ const latest = this.store.listWorkCommands(workId).at(-1);
927
+ const open = !["completed", "failed", "cancelled"].includes(this.store.getWork(workId)?.status ?? "completed");
928
+ if (open && latest?.kind === "cancel" && latest.delivery !== "accepted") {
929
+ try { await this.reconcile(workId); } catch (error) { if (generation === this.generation) this.refuse(workId, error); }
930
+ }
931
+ const evidence = this.collect(workId, generation);
932
+ if (evidence) this.applyVerdict(workId, evidence, classify(evidence));
933
+ } catch (error) { this.fail(error); }
934
+ }
935
+
936
+ private applyVerdict(workId: string, evidence: ReconcileEvidence, verdict: ReconcileVerdict): void {
937
+ const latest = evidence.latest;
938
+ if (verdict.kind === "keep" || !latest) {
939
+ const held = this.reconciled.get(workId);
940
+ if (held && !this.stillClosed(held)) {
941
+ this.reconciled.delete(workId);
942
+ this.options.onReconciled?.();
943
+ }
944
+ return;
945
+ }
946
+ const id = reconcileWorkId({ reason: verdict.reason, commandId: latest.id });
947
+ const item = (status: ReconcileItem["status"]): ReconcileItem => ({ workId, title: this.work(workId).title, kind: verdict.kind, reason: verdict.reason,
948
+ status, commandId: latest.id, commandSeq: latest.seq, evidence: verdict.evidence, at: new Date().toISOString() });
949
+ if (verdict.kind === "close") {
950
+ const closed = this.store.closeByReconcile(workId, latest.seq, verdict.status, verdict.reason, verdict.evidence);
951
+ this.audit({ id, target: workId, verdict: "act", evidence: verdict.evidence, method: "state", ok: closed !== null, ...(closed ? {} : { error: "close_refused" }) });
952
+ if (!closed) return;
953
+ this.reconciled.set(workId, item(closed.status));
954
+ this.notify(workId);
955
+ this.options.onReconciled?.();
956
+ return;
957
+ }
958
+ const alert = this.store.recordWatchAlert(workId, { sourceKey: id, commandSeq: latest.seq,
959
+ summary: RECONCILE_CONFIRM_SUMMARIES[verdict.reason] ? say(RECONCILE_CONFIRM_SUMMARIES[verdict.reason]!) : verdict.reason,
960
+ details: { ...verdict.evidence, source: "watcher", reconcile: true, requiresUserAction: true } });
961
+ this.reconciled.set(workId, item(evidence.status));
962
+ if (alert) {
963
+ this.audit({ id, target: workId, verdict: "confirm", evidence: verdict.evidence, method: null, ok: true });
964
+ this.options.onUpdate?.(this.work(workId));
965
+ }
966
+ this.options.onReconciled?.();
967
+ }
968
+
969
+ private audit(line: Pick<AuditLine, "id" | "target" | "verdict" | "evidence" | "method" | "ok" | "error">): void {
970
+ try {
971
+ this.options.audit?.append({ at: new Date().toISOString(), trigger: "work_event", actor: "reconcile", collector: "reconcile-work", ...line });
972
+ } catch (error) { this.fail(error); }
973
+ }
785
974
  }
package/web/DESIGN.md CHANGED
@@ -179,3 +179,36 @@ remain visible. No animation, network, remote fonts, new settings or exports.
179
179
  Source foldouts retain their local open state across streaming updates.
180
180
  Desktop/phone light/dark, enlarged text, source/copy and reference QA belong
181
181
  to the parent integration lane; local unit checks do not claim visual approval.
182
+
183
+ ## 11. Agent collaboration cards and targets (292, C-10)
184
+ Brief. Primary action: none by default; 요청 취소 is the one prominent (danger) control and appears only while a request
185
+ can still move. Priorities: 1) who asked whom and the request text, 2) the state word, 3) the result and its files.
186
+ Left out on purpose: a per-request avatar row, a progress bar, a duplicate state badge in the head of the transcript,
187
+ and any success wording for an action the gateway refused.
188
+ Layout: one `collaboration-card` per request (`data-request-id`), a block in the message list aligned with message text
189
+ (68px left, 640px max; 16px/8px margins and full width at 640px and below); the message list stays the only scroll owner.
190
+ The card is drawn at the first stored event that names the request (`message.collaboration`) and is updated in place by
191
+ later events (`agent.collaboration.status`, the reply, blocked/delivery notes) and by the owner listing
192
+ (`GET /api/agent-collaboration/requests?conv=`), which is the gateway's truth for kind, state and revision; a replayed
193
+ event never adds a second card. web/collaboration.js decides, app.js draws.
194
+ Tokens: the existing `--bg-card`, `--bg-input`, `--border`, `--text`, `--muted`, `--ok`, `--warn`, `--danger`, the
195
+ 1px #4e5058 hairline and 4px/6px radii; no side stripe. The state is a dot plus a monospace word, never colour alone:
196
+ ok (완료), busy (대기 중, 진행 중, 다른 에이전트의 답 대기, 결과 준비 중), warn (내 답 대기, 취소 요청됨, 전달 미확인, 정의
197
+ 변경으로 미실행, 상태 미확인), danger (실패, 중단됨, 취소됨). An unknown or unconfirmed state never reads as done.
198
+ Head: title (보낸/받은 협업 요청), kind tag (작업/질문, from the listing), state, time. Parties: 보낸 쪽 → 받는 쪽; the
199
+ sender of a received request is its actor, the recipient of a sent request reads 다른 에이전트 until the gateway names it.
200
+ Body: the request text as DOM text; 요청과 함께 보낸 파일 and the 결과 files as rows (`data-artifact-id`, name, size,
201
+ 내려받기); the result text or 회신에 본문이 없습니다; 회신 보냄 on the answering side; notes for waiting owner, blocked,
202
+ unconfirmed delivery and definition change. A status line (`role=status`) under the body carries the last action's
203
+ answer. Actions: 요청 취소 (`data-action=collaboration-cancel`, needs the listing's revision), 다시 확인
204
+ (`collaboration-recheck`), 내려받기 (`collaboration-download`, saved only when the gateway's sha256 header and the
205
+ browser's digest match the manifest). Owner actions answered 503 `owner_auth_unavailable` by the gateway are shown as
206
+ "not available yet", never as success. Controls keep 24px (44px on phones) targets and the #a3acff focus ring.
207
+ Agent editor: the gateway's `collaboration` group and `collaboration.targets` rows are drawn (the registry marks them
208
+ hidden for older webs) as a check list of the other agents by name (`pe-skills pe-targets`, the skills list's shape),
209
+ never the agent itself; a saved id the list no longer carries stays checked with a 없음 tag. Knowledge files: a source/
210
+ file carries its origin tag (직접 올림 or 협업 사본, the latter in the accent ink) and the copy's delete confirmation says
211
+ only this copy goes. Dictionaries: `collab.*`, `agents.targets.*`, `agents.file.origin.*` in web/i18n/agents.js.
212
+ Verification: test/unit/agent-collaboration-web.test.ts (guards doubling as mutation watchers in
213
+ test/mutation/agent-collaboration-web.test.ts) and the Aside browser QA test/fixtures/agent-collaboration-web-qa.ts at
214
+ 1440x900 and 390x844 in both colour schemes (the UI ships one dark palette; light is the media emulation).