@zq-silk/yui 2.6.0 → 2.7.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 (72) hide show
  1. package/README.md +23 -0
  2. package/dist/cli/commandCatalog.js +28 -2
  3. package/dist/commands/globalRoleCommands.js +22 -2
  4. package/dist/commands/inboxCommands.js +352 -0
  5. package/dist/commands/roleSkillValidation.js +2 -0
  6. package/dist/commands/taskCommands.js +32 -4
  7. package/dist/context/currentSkills.js +48 -0
  8. package/dist/context/roleSessionContext.js +13 -16
  9. package/dist/context/runContextPack.js +2 -0
  10. package/dist/context/runInputContract.js +1 -1
  11. package/dist/context/sessionBootstrapManifest.js +27 -4
  12. package/dist/context/skillCatalog.js +2 -3
  13. package/dist/context/taskContext.js +2 -0
  14. package/dist/controlPlaneCli.js +18 -1
  15. package/dist/controller/controller.js +3 -2
  16. package/dist/controller/fileSchedulerStoreAdapter.js +19 -4
  17. package/dist/controller/globalInputDelivery.js +73 -15
  18. package/dist/controller/globalRuntimeAttention.js +1 -1
  19. package/dist/controller/operatorNotification.js +6 -1
  20. package/dist/controller/providerRetryDelivery.js +3 -5
  21. package/dist/controller/runtime.js +2 -2
  22. package/dist/doctor/doctor.js +25 -2
  23. package/dist/executor/effectiveLaunch.js +2 -2
  24. package/dist/executor/fileRoleLaunchPlanner.js +4 -6
  25. package/dist/interaction/notificationPresentation.js +2 -2
  26. package/dist/interaction/operatorPresentation.js +1 -1
  27. package/dist/message/globalInputBacklog.js +69 -0
  28. package/dist/message/globalMessageQuery.js +1 -0
  29. package/dist/message/inboxReceipt.js +32 -0
  30. package/dist/message/message.js +6 -1
  31. package/dist/message/messageContinuation.js +34 -13
  32. package/dist/message/taskInboxOwnership.js +21 -0
  33. package/dist/nativeAgent/session/backends.js +1 -1
  34. package/dist/observability/runtimeIdentity.js +1 -1
  35. package/dist/resources/sqliteResourceRegistry.js +1 -1
  36. package/dist/runtime/agentHost.js +105 -16
  37. package/dist/scheduler/operatorEvent.js +49 -0
  38. package/dist/scheduler/operatorInputNotificationProcessor.js +5 -4
  39. package/dist/scheduler/taskWake.js +5 -0
  40. package/dist/sqlite/driver.js +108 -0
  41. package/dist/storage/sqliteSchema.js +11 -1
  42. package/dist/storage/sqliteStore.js +60 -1
  43. package/dist/storage/storageSchema.js +1 -1
  44. package/dist/storage/storageVersions.js +1 -1
  45. package/dist/storage/storeRpc.js +1 -0
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +1 -1
  47. package/dist/task/completionReadiness.js +11 -4
  48. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  49. package/docs/managed-turn-and-session-runtime.md +81 -5
  50. package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -3
  51. package/docs/release-workflow.md +47 -2
  52. package/docs/release-workflow.zh-CN.md +34 -1
  53. package/docs/storage-baseline.md +17 -7
  54. package/docs/storage-baseline.zh-CN.md +11 -5
  55. package/docs/task-delivery.md +17 -8
  56. package/docs/task-delivery.zh-CN.md +11 -5
  57. package/docs/testing/verification-levels.md +22 -4
  58. package/docs/testing/verification-levels.zh-CN.md +8 -1
  59. package/i18n/README.zh-CN.md +19 -0
  60. package/package.json +5 -2
  61. package/skills/yui-leader/SKILL.md +28 -11
  62. package/skills/yui-leader/VERSION +1 -0
  63. package/skills/yui-operator/SKILL.md +10 -2
  64. package/skills/yui-operator/VERSION +1 -0
  65. package/skills/yui-operator/references/task-delivery.md +2 -0
  66. package/skills/yui-reviewer/SKILL.md +8 -1
  67. package/skills/yui-reviewer/VERSION +1 -0
  68. package/skills/yui-runtime/SKILL.md +94 -11
  69. package/skills/yui-runtime/VERSION +1 -0
  70. package/skills/yui-runtime/references/publication.md +16 -5
  71. package/skills/yui-worker/SKILL.md +8 -1
  72. package/skills/yui-worker/VERSION +1 -0
package/README.md CHANGED
@@ -67,6 +67,29 @@ Native first-run confirmations may still require your input.
67
67
  npm install -g @zq-silk/yui
68
68
  ```
69
69
 
70
+ SQLite is selected lazily before opening your Home: Yui probes a disposable
71
+ in-memory connection with `sqlite-modern` (`better-sqlite3@13.0.3`, N-API) first,
72
+ then tries `sqlite-compat` (`better-sqlite3@10.1.0`) only for identified missing
73
+ native bindings, Node ABI or system-library incompatibility. Each process keeps
74
+ its successful selection. Home permissions, corruption, locks, SQL and migration
75
+ errors never trigger a driver switch. `yui doctor` reports the driver version,
76
+ actual SQLite engine and fallback reason separately from Home health.
77
+
78
+ The N-API driver avoids the native `ObjectWrap` garbage-collection abort seen
79
+ with the earlier driver on Node 24.19/24.20. It requires Node-API 10;
80
+ runtimes with an older API (including Node 20 and Node 22.9) skip it before
81
+ native loading and use the compatibility candidate. No global Node change
82
+ or Home migration is needed for this dependency correction.
83
+
84
+ npm attempts **both** fixed optional aliases during installation; it does not
85
+ install the older one only after the newer one fails. This increases download
86
+ size and installation/build time. Optional installation failures may leave npm
87
+ successful, but Yui cannot run without a usable candidate; do not use
88
+ `--omit=optional`. The compatibility driver uses older SQLite (3.46.0), without
89
+ later fixes, and is not guaranteed on every old glibc/toolchain or newer Node
90
+ ABI. The supported Node range above is unchanged; an OS/toolchain upgrade may
91
+ still be necessary. Selection does not downgrade or migrate Home data.
92
+
70
93
  ### 2. Set up, yourself or with an Agent
71
94
 
72
95
  Run the interactive setup:
@@ -190,6 +190,11 @@ const roleChildren = [
190
190
  { name: "unbind", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Unbind a dormant Agent from a global Role.", usage: "yui config role unbind <role> <agent-id>" }
191
191
  ];
192
192
  const globalSessionChildren = [
193
+ {
194
+ name: "skills", discovery: { surface: "managed", audiences: ["operator", "leader", "assignment", "global", "unbound"] },
195
+ summary: "Check current Skill paths, versions and availability for this managed Session without reading bodies.",
196
+ usage: "yui session skills"
197
+ },
193
198
  { name: "enter", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Enter a global Role's native session.", usage: "yui session enter <role>" },
194
199
  {
195
200
  name: "context", discovery: { surface: "managed", audiences: ["operator", "global", "unbound"] },
@@ -366,7 +371,7 @@ const taskChildren = [
366
371
  }
367
372
  ]
368
373
  },
369
- { name: "reopen", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Explicitly reopen completed or cancelled intent without replaying historical inputs.", usage: "yui task reopen <id>" },
374
+ { name: "reopen", discovery: { surface: "public", audiences: ["public", "operator", "leader"] }, summary: "Reopen a completed Task without replaying historical inputs; a current Leader is limited to its own Task. Restoring cancelled intent requires user or Operator authority; an independent execution stop is preserved.", usage: "yui task reopen <id>" },
370
375
  {
371
376
  name: "cancel", discovery: { surface: "public", audiences: ["public", "operator", "leader"] },
372
377
  summary: "Stop pursuing a Task without claiming its processes stopped.",
@@ -1128,7 +1133,7 @@ export const ROOT_COMMAND = buildNode({
1128
1133
  { id: "general", title: "General", entries: [
1129
1134
  "help", "version", "update", "upgrade", "setup", "start", "doctor"
1130
1135
  ] },
1131
- { id: "workflow", title: "Workflow", entries: ["agent", "operator", "role", "project", "task"] },
1136
+ { id: "workflow", title: "Workflow", entries: ["agent", "operator", "role", "inbox", "project", "task"] },
1132
1137
  { id: "configuration", title: "Configuration", entries: ["config"] },
1133
1138
  { id: "operations", title: "Operations", entries: ["web", "controller", "session", "execution", "capability", "job", "jobs", "telemetry", "release"] },
1134
1139
  { id: "resources", title: "Resources", entries: ["resources"] },
@@ -1154,6 +1159,26 @@ export const ROOT_COMMAND = buildNode({
1154
1159
  options: catalogCommandOptions(name) }))
1155
1160
  ]
1156
1161
  },
1162
+ {
1163
+ name: "inbox",
1164
+ summary: "Read and explicitly handle the current managed Agent's own inbox.",
1165
+ sections: [{ id: "commands", title: "Commands", entries: ["list", "show", "claim", "ack"] }],
1166
+ children: [
1167
+ { name: "list", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1168
+ summary: "Discover bounded own-address entries without consuming them.",
1169
+ usage: "yui inbox list [--limit <1..100>] [--cursor <cursor>]", options: ["--limit", "--cursor"] },
1170
+ { name: "show", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1171
+ summary: "Read an exact original or fixed notification window without acknowledgement.",
1172
+ usage: "yui inbox show <entry> [--cursor <cursor>]", options: ["--cursor"] },
1173
+ { name: "claim", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1174
+ summary: "Reserve 1..16 exact entries for handling in this turn; never starts a model turn.",
1175
+ usage: "yui inbox claim <entry> [<entry> ...] --request-id <id>", options: ["--request-id"] },
1176
+ { name: "ack", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1177
+ summary: "Confirm handling of claimed entries with an auditable disposition, not Provider acceptance.",
1178
+ usage: "yui inbox ack <entry> [<entry> ...] --request-id <id> --reason <disposition>",
1179
+ options: ["--request-id", "--reason"] }
1180
+ ]
1181
+ },
1157
1182
  {
1158
1183
  name: "role", discovery: { surface: "public", audiences: ["public", "operator"] },
1159
1184
  summary: "Queue input or control an exact current Global Role Turn.",
@@ -1584,6 +1609,7 @@ export const ROOT_COMMAND = buildNode({
1584
1609
  "yui session reconcile --report"
1585
1610
  ],
1586
1611
  sections: [
1612
+ { id: "instructions", title: "Managed instructions", entries: ["skills"] },
1587
1613
  { id: "global", title: "Global Role sessions", entries: ["context", "enter", "record", "replace"] },
1588
1614
  { id: "maintenance", title: "Maintenance", entries: ["stop"] },
1589
1615
  { id: "recovery", title: "Recovery", entries: ["reconcile", "retry"] }
@@ -3,6 +3,9 @@ import { hasManagedIdentity } from "../runtime/managedIdentity.js";
3
3
  import { boundedDocument, recordPage, readOptions, readDigest, messageReceipt } from "../output/boundedRead.js";
4
4
  import { controlProviderRetry, providerRetryProjection, renderProviderRetry } from "../runtime/providerRetry.js";
5
5
  import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
6
+ import { globalInputBacklog } from "../message/globalInputBacklog.js";
7
+ import { checkCurrentSkills, SKILL_REUSE_RULE } from "../context/currentSkills.js";
8
+ import { roleSkillIds } from "../context/roleSessionContext.js";
6
9
  import { createRoleSessionSet, recordRoleAgentSession } from "../executor/agentExecutor.js";
7
10
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
8
11
  import { managedGlobalRoleWorkspace } from "../storage/homeLayout.js";
@@ -129,13 +132,21 @@ function roleContext(args, store, options) {
129
132
  const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
130
133
  const sessions = store.getGlobalRoleSessionSet(name);
131
134
  const session = sessions?.sessions[role.activeAgentId];
132
- // Only the controlled Endpoint's accepted receipt consumes a queued Message.
135
+ const currentSkills = checkCurrentSkills(options.yuiHome ?? environment.YUI_HOME, roleSkillIds(session?.effective.context ?? role, role.name === "operator" ? "operator" : "global"));
136
+ // Native delivery and explicit Agent handling settle the same queued input
137
+ // through distinct receipts; a context read does neither.
133
138
  const messages = store.listGlobalRoleMessages(name);
134
139
  const queued = messages.filter((message) => message.delivery === undefined
140
+ && message.inbox?.ack === undefined
135
141
  && message.notDelivered === undefined
136
142
  && (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
137
143
  const pending = recordPage(queued.map(globalMessageSummary), `global:${name}/messages/pending`, { limit: 8 });
138
144
  const recent = recordPage([...messages].reverse().map(globalMessageSummary), `global:${name}/messages/all`, { limit: 8 });
145
+ const observedAt = new Date();
146
+ const { oldestAgeMs, ...backlog } = globalInputBacklog(queued, sessions, observedAt, {
147
+ attention: name === "operator" ? store.getWorkMailbox({ kind: "operator" }) : null,
148
+ lifecycle: store.getWorkMailbox({ kind: "global-role-runtime", roleName: name })
149
+ });
139
150
  const context = Object.freeze({
140
151
  schemaVersion: 1,
141
152
  protocol: "yui-managed-context/v1",
@@ -173,7 +184,9 @@ function roleContext(args, store, options) {
173
184
  // Reading pending input is neither delivery nor implementation.
174
185
  pending,
175
186
  recent,
187
+ backlog,
176
188
  reads: {
189
+ ownInbox: "yui inbox list --json",
177
190
  messages: `role message list ${name}`,
178
191
  pending: `role message list ${name} --pending`,
179
192
  detail: `role message show ${name} <message-id>`
@@ -183,10 +196,16 @@ function roleContext(args, store, options) {
183
196
  // Historical interrupt receipts are available through Session inspection;
184
197
  // only this Turn's exact identity is required at entry.
185
198
  sessionManifestPath: environment.YUI_SESSION_MANIFEST,
199
+ skillCheckCommand: "yui session skills --json",
200
+ currentSkills,
201
+ skillLoadingRule: SKILL_REUSE_RULE,
186
202
  skillPackages: session?.effective.skillPackages ?? null,
187
203
  cliCommand: "yui"
188
204
  });
189
- const data = boundedDocument(context, `global:${name}/context`, parsed.cursor);
205
+ // Elapsed wall-clock time is live display metadata, not a changing source
206
+ // fact: keep it outside the digest so a long Context remains pageable.
207
+ const data = { ...boundedDocument(context, `global:${name}/context`, parsed.cursor),
208
+ backlogObservation: { observedAt: observedAt.toISOString(), oldestAgeMs } };
190
209
  return { kind: "output", data, output: [
191
210
  `Role Context: ${role.name}`,
192
211
  `Scope: global (${context.identity.roleKind})`,
@@ -626,6 +645,7 @@ function globalRoleMessage(args, store, options) {
626
645
  if (parsed.positionals.length !== 0)
627
646
  throw usageError("Role message list <role> [--pending] [--limit <n>] [--cursor <cursor>].");
628
647
  const selected = pending ? messages.filter(message => message.delivery === undefined
648
+ && message.inbox?.ack === undefined
629
649
  && message.notDelivered === undefined
630
650
  && (message.interruptThen !== undefined || message.inputControl?.action === "queue")) : [...messages].reverse();
631
651
  data = recordPage(selected.map(globalMessageSummary), `global:${name}/messages/${pending ? "pending" : "all"}`, parsed);
@@ -0,0 +1,352 @@
1
+ import { requireManagedGlobalCaller, requireManagedTaskCaller } from "../runtime/managedCaller.js";
2
+ import { boundedDocument, readDigest, readOptions, recordPage } from "../output/boundedRead.js";
3
+ import { claimPending, completeProcessing } from "../coordination/workMailbox.js";
4
+ import { createTaskWake, latestTaskWake } from "../scheduler/taskWake.js";
5
+ import { expandTaskMessageResult } from "../message/message.js";
6
+ import { messageAssignmentBlocker } from "../message/messageContinuation.js";
7
+ import { isLeaderSteer, latestMessageUpdate, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
8
+ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
9
+ import { isTaskRecordRetired, retiredTaskRecordIds } from "../task/taskRecordRetirement.js";
10
+ const target = (address) => ({ kind: "role", taskId: address.taskId, roleName: address.roleName });
11
+ const source = (address) => `inbox:${address.scope}:${address.taskId ?? ""}:${address.roleName}`;
12
+ const pendingId = (pending) => `pending-${readDigest(pending)}`;
13
+ const sameSession = (a, b) => a.scope === b.scope && a.taskId === b.taskId
14
+ && a.roleName === b.roleName && a.agentId === b.agentId && a.nativeSessionId === b.nativeSessionId;
15
+ function addressOf(store, env) {
16
+ if (env.YUI_SESSION_SCOPE === "global") {
17
+ const caller = requireManagedGlobalCaller(store, env);
18
+ return { scope: "global", roleName: caller.roleName, agentId: caller.agentId, nativeSessionId: caller.nativeSessionId };
19
+ }
20
+ const caller = requireManagedTaskCaller(store, env);
21
+ if (caller.roleName !== "leader" && caller.currentRunId === undefined) {
22
+ throw new Error("An Assignment inbox requires its current active AgentRun.");
23
+ }
24
+ return { scope: "task", taskId: caller.taskId, roleName: caller.roleName,
25
+ agentId: caller.agentId, nativeSessionId: caller.nativeSessionId,
26
+ ...(caller.currentRunId === undefined ? {} : { runId: caller.currentRunId }) };
27
+ }
28
+ function ownsMessage(store, address, message) {
29
+ if (address.roleName === "leader" && message.taskId === address.taskId && isLeaderSteer(message))
30
+ return true;
31
+ if (address.runId === undefined || message.taskId !== address.taskId
32
+ || message.recipient?.roleName !== address.roleName)
33
+ return false;
34
+ const current = store.getRun(address.taskId, address.runId);
35
+ const owner = message.recipient.ownerRunId === undefined ? null
36
+ : store.getRun(address.taskId, message.recipient.ownerRunId);
37
+ return current !== null && owner !== null && current.roleName === owner.roleName
38
+ && owner.workItemId === message.recipient.workItemId && owner.reviewRoundId === message.recipient.reviewRoundId
39
+ && current.workItemId === owner.workItemId && current.reviewRoundId === owner.reviewRoundId
40
+ && (owner.id === current.id || messageAssignmentBlocker(store, message) === undefined);
41
+ }
42
+ function entries(store, address) {
43
+ if (address.scope === "global")
44
+ return store.listGlobalRoleMessages(address.roleName)
45
+ .filter(message => message.inputControl !== undefined || message.interruptThen !== undefined);
46
+ const messages = store.listMessages(address.taskId).filter(message => ownsMessage(store, address, message));
47
+ return address.roleName === "leader" ? [...store.listTaskWakes(address.taskId), ...messages] : messages;
48
+ }
49
+ function getEntry(store, address, id) {
50
+ const entry = entries(store, address).find(entry => entry.id === id);
51
+ if (entry === undefined)
52
+ throw new Error("Inbox entry is not in this Agent's current authorized receiving scope.");
53
+ return entry;
54
+ }
55
+ function isWake(entry) { return "fromCursor" in entry; }
56
+ function assertSelectedTarget(address, message) {
57
+ const selected = message.inputControl?.expectedSessionId;
58
+ const globalTarget = "roleName" in message ? message.deliveryTarget : undefined;
59
+ if (selected !== undefined && selected !== address.nativeSessionId
60
+ || globalTarget !== undefined && (globalTarget.agentId !== address.agentId
61
+ || globalTarget.nativeSessionId !== address.nativeSessionId)) {
62
+ throw new Error("The original input explicitly targets another native Session; inbox handling cannot retarget it.");
63
+ }
64
+ }
65
+ function save(store, address, entry) {
66
+ if (isWake(entry))
67
+ store.saveTaskWake(address.taskId, entry);
68
+ else if (address.scope === "global")
69
+ store.updateGlobalRoleMessage(entry);
70
+ else
71
+ store.updateMessage(address.taskId, entry);
72
+ }
73
+ /** The same durable claim is checked at automatic delivery admission. No lease
74
+ * expires, and neither a receipt nor a cursor gives a caller identity. */
75
+ function blocker(store, address, entry) {
76
+ if (!isWake(entry) && address.scope === "task"
77
+ && isTaskRecordRetired(store.listEvents(address.taskId), "message", entry.id))
78
+ return "message-retired";
79
+ if (entry.inbox?.ack !== undefined)
80
+ return "acknowledged";
81
+ if (entry.inbox !== undefined) {
82
+ return sameSession(entry.inbox.claim.actor, address) ? undefined : "claimed-by-previous-session";
83
+ }
84
+ if (isWake(entry)) {
85
+ if (entry.status === "consumed")
86
+ return "already-delivered";
87
+ return "automatic-notification-owns-window";
88
+ }
89
+ if (address.scope === "task" && taskMessageInboxReceipt(store, entry) !== undefined) {
90
+ return "claimed-by-notification-window";
91
+ }
92
+ if (entry.interruptThen !== undefined)
93
+ return "explicit-interrupt-handoff";
94
+ if (entry.control !== undefined && entry.control.outcome !== "rejected")
95
+ return `delivery-${entry.control.outcome}`;
96
+ // Both steer paths reserve pending control before submission and arbitrate
97
+ // with inbox claims. No control is unsubmitted, not an unknown delivery.
98
+ if (address.scope === "global") {
99
+ const message = entry;
100
+ if (message.delivery !== undefined)
101
+ return `already-delivered-${message.delivery.via}`;
102
+ if (message.deliveryTarget !== undefined && (message.deliveryTarget.nativeSessionId !== address.nativeSessionId
103
+ || message.deliveryTarget.agentId !== address.agentId))
104
+ return "selected-native-session-changed";
105
+ if (message.inputControl?.expectedSessionId !== undefined
106
+ && message.inputControl.expectedSessionId !== address.nativeSessionId)
107
+ return "selected-native-session-changed";
108
+ const turn = store.getGlobalRoleSessionSet(address.roleName)?.providerBinding?.run;
109
+ if (turn?.attemptId === `global-input:${address.roleName}/${entry.id}`)
110
+ return `native-attempt-${turn.status}`;
111
+ }
112
+ else {
113
+ const message = entry;
114
+ if (message.continuation?.runId !== undefined)
115
+ return "continuation-already-dispatched";
116
+ if (message.inputControl?.expectedSessionId !== undefined
117
+ && message.inputControl.expectedSessionId !== address.nativeSessionId)
118
+ return "selected-native-session-changed";
119
+ return address.roleName === "leader" && isLeaderSteer(message) ? undefined : messageAssignmentBlocker(store, message);
120
+ }
121
+ return undefined;
122
+ }
123
+ function checkMutation(store, address) {
124
+ const owner = address.scope === "global" ? { scope: "global", roleName: address.roleName }
125
+ : { scope: "task", taskId: address.taskId, roleName: address.roleName };
126
+ if (hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget(owner)))) {
127
+ throw new Error("Inbox cannot change while this runtime is being released.");
128
+ }
129
+ if (address.scope === "task") {
130
+ const task = store.getTask(address.taskId);
131
+ if (!task || !["active", "draft"].includes(task.status) || task.executionGate.state !== "enabled") {
132
+ throw new Error("Inbox handling requires an open Task with execution enabled.");
133
+ }
134
+ }
135
+ }
136
+ function summary(store, address, entry) {
137
+ return { id: entry.id, kind: isWake(entry) ? "notification-window" : "message",
138
+ createdAt: entry.createdAt, summary: isWake(entry) ? entry.reasons.join(", ").slice(0, 240) : entry.body.slice(0, 240),
139
+ blocked: blocker(store, address, entry) ?? null, inbox: entry.inbox ?? null,
140
+ read: `yui inbox show ${entry.id}`, ...(isWake(entry)
141
+ ? { originals: `yui task wake show ${entry.taskId} ${entry.id} --json` } : {}) };
142
+ }
143
+ function receipt(entry) {
144
+ return { id: entry.id, inbox: entry.inbox,
145
+ ...(isWake(entry) ? { originals: `yui task wake show ${entry.taskId} ${entry.id} --json` } : {}) };
146
+ }
147
+ /** Own-address command: there is deliberately no --role, --task or --session.
148
+ * Reads do not claim. Exact claim precedes semantic handling; exact ack follows
149
+ * full original reads and actual handling. A batch is independent per item. */
150
+ export function runInboxCommand(args, store, env, now = new Date()) {
151
+ const [action, ...tail] = args;
152
+ const address = addressOf(store, env);
153
+ if (action === "list") {
154
+ const parsed = readOptions(tail);
155
+ if (parsed.positionals.length)
156
+ throw new Error("Inbox list accepts only --limit and --cursor.");
157
+ const retired = address.scope === "task" ? retiredTaskRecordIds(store.listEvents(address.taskId), "message") : new Set();
158
+ const selected = entries(store, address).filter(entry => (isWake(entry) || !retired.has(entry.id))
159
+ && entry.inbox?.ack === undefined
160
+ && (isWake(entry) ? entry.status !== "consumed"
161
+ : address.scope === "global" ? entry.delivery === undefined
162
+ : entry.continuation?.runId === undefined));
163
+ const items = selected.map(entry => summary(store, address, entry));
164
+ if (address.scope === "task" && address.roleName === "leader") {
165
+ const mailbox = store.getWorkMailbox(target(address));
166
+ if (mailbox?.pending != null)
167
+ items.push({ id: pendingId(mailbox.pending), kind: "notification-window",
168
+ summary: mailbox.pending.reasons.join(", ").slice(0, 240),
169
+ blocked: mailbox.processing === null && !unfinishedLeaderInbox(store, address.taskId) ? null : "earlier-window-owned",
170
+ read: `yui inbox show ${pendingId(mailbox.pending)}` });
171
+ }
172
+ return recordPage(items, source(address), parsed);
173
+ }
174
+ if (action === "show") {
175
+ const parsed = readOptions(tail, ["--cursor"]);
176
+ if (parsed.positionals.length !== 1)
177
+ throw new Error("Inbox show requires one exact entry.");
178
+ const id = parsed.positionals[0];
179
+ if (id.startsWith("pending-") && address.scope === "task" && address.roleName === "leader") {
180
+ const pending = store.getWorkMailbox(target(address))?.pending;
181
+ if (!pending || id !== pendingId(pending))
182
+ throw new Error("Pending window changed; list the inbox again.");
183
+ return boundedDocument({ id, ...pending,
184
+ note: "Claim this exact window before processing; claim returns its durable wake and original-read command." }, `${source(address)}/${id}`, parsed.cursor);
185
+ }
186
+ const entry = getEntry(store, address, id);
187
+ const value = !isWake(entry) && address.scope === "task"
188
+ ? expandTaskMessageResult(entry, (task, run) => store.getRun(task, run)) : entry;
189
+ return boundedDocument(value, `${source(address)}/${id}`, parsed.cursor);
190
+ }
191
+ if (action !== "claim" && action !== "ack")
192
+ throw new Error("Inbox commands: list, show, claim, ack.");
193
+ const parsed = readOptions(tail, ["--request-id", "--reason"]);
194
+ const ids = parsed.positionals;
195
+ const requestId = parsed.values.get("--request-id");
196
+ const reason = parsed.values.get("--reason");
197
+ if (!requestId || requestId.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(requestId)) {
198
+ throw new Error("Inbox mutation requires a stable --request-id (1..128 safe characters).");
199
+ }
200
+ if (!ids.length || ids.length > 16 || new Set(ids).size !== ids.length) {
201
+ throw new Error("Select 1..16 distinct exact inbox entries; no wildcard acknowledgement.");
202
+ }
203
+ if (action === "ack" && (!reason?.trim() || Buffer.byteLength(reason) > 2048)) {
204
+ throw new Error("Acknowledgement requires --reason describing handling or why no action remains (up to 2048 bytes).");
205
+ }
206
+ if (action === "claim" && reason !== undefined)
207
+ throw new Error("Claim does not record a disposition; use ack after handling.");
208
+ const apply = (id) => store.transaction(tx => {
209
+ const current = addressOf(tx, env);
210
+ checkMutation(tx, current);
211
+ let entry;
212
+ if (id.startsWith("pending-") && current.scope === "task" && current.roleName === "leader" && action === "claim") {
213
+ // Retry the exact logical operation even after its pending batch moved.
214
+ const existing = tx.listTaskWakes(current.taskId).find(wake => wake.inbox?.claim.requestId === requestId && sameSession(wake.inbox.claim.actor, current));
215
+ if (existing) {
216
+ if (existing.inboxPendingId !== id) {
217
+ throw new Error("Inbox request id was reused for a different window.");
218
+ }
219
+ return receipt(existing);
220
+ }
221
+ const mailbox = tx.getWorkMailbox(target(current));
222
+ if (!mailbox?.pending || pendingId(mailbox.pending) !== id)
223
+ throw new Error("Pending window changed; list the inbox again.");
224
+ if (mailbox.processing !== null || unfinishedLeaderInbox(tx, current.taskId)) {
225
+ throw new Error("An earlier automatic or active inbox claim owns the notification window.");
226
+ }
227
+ // A pending Leader input with an exact transport/interrupt owner cannot be
228
+ // hidden inside an actively consumed window.
229
+ for (const ref of mailbox.pending.refs) {
230
+ if (ref.type !== "message")
231
+ continue;
232
+ const message = tx.listMessages(current.taskId).find(message => message.id === ref.id);
233
+ if (message !== undefined && taskMessageInboxReceipt(tx, message) !== undefined
234
+ || message?.interruptThen !== undefined
235
+ || message?.control !== undefined && message.control.outcome !== "rejected"
236
+ || message?.inputControl?.expectedSessionId !== undefined
237
+ && message.inputControl.expectedSessionId !== current.nativeSessionId) {
238
+ throw new Error("Window contains an input with an existing or foreign delivery owner; inspect its original receipt.");
239
+ }
240
+ }
241
+ const latest = latestTaskWake(tx.listTaskWakes(current.taskId));
242
+ // Pin each mutable Draft original's existing update event, not a clock
243
+ // comparison. A later edit is distinct input even within the same tick.
244
+ const refs = [...mailbox.pending.refs];
245
+ for (const ref of mailbox.pending.refs) {
246
+ if (ref.type !== "message" || ref.taskId !== current.taskId)
247
+ continue;
248
+ const message = tx.listMessages(current.taskId).find(message => message.id === ref.id);
249
+ const revision = message === undefined ? undefined : latestMessageUpdate(tx, message);
250
+ if (revision !== undefined && !refs.some(ref => ref.type === "event" && ref.id === revision.id)) {
251
+ refs.push({ type: "event", taskId: current.taskId, id: revision.id });
252
+ }
253
+ }
254
+ const wake = createTaskWake({ id: tx.nextTaskWakeId(current.taskId), taskId: current.taskId,
255
+ reasons: mailbox.pending.reasons, refs,
256
+ fromCursor: latest?.toCursor ?? tx.getTask(current.taskId).createdAt,
257
+ toCursor: new Date(Math.max(now.getTime(), Date.parse(mailbox.pending.lastQueuedAt))).toISOString(), now });
258
+ entry = { ...wake, inbox: { claim: { requestId, actor: current, at: now.toISOString() } },
259
+ inboxPendingId: id };
260
+ tx.saveWorkMailbox(claimPending(mailbox, { batchId: `inbox:${wake.id}`,
261
+ owner: `agent-inbox:${wake.id}`, startedAt: now.toISOString() }));
262
+ }
263
+ else {
264
+ entry = getEntry(tx, current, id);
265
+ if (action === "claim") {
266
+ if (entry.inbox !== undefined) {
267
+ if (!sameSession(entry.inbox.claim.actor, current))
268
+ throw new Error("Claim belongs to a previous Session; read the original and prior effects, then explicitly acknowledge only after finishing its handling.");
269
+ if (entry.inbox.claim.requestId !== requestId)
270
+ throw new Error("Entry already claimed; retain the original request id.");
271
+ return receipt(entry);
272
+ }
273
+ const blocked = blocker(tx, current, entry);
274
+ if (blocked !== undefined)
275
+ throw new Error(`Inbox entry cannot be claimed: ${blocked}.`);
276
+ for (const previous of entries(tx, current)) {
277
+ if (previous.id === entry.id)
278
+ break;
279
+ if (isWake(previous) || previous.inbox?.ack !== undefined)
280
+ continue;
281
+ if (current.scope === "task"
282
+ && isTaskRecordRetired(tx.listEvents(current.taskId), "message", previous.id))
283
+ continue;
284
+ if (current.scope === "global" && (previous.delivery !== undefined
285
+ || previous.notDelivered !== undefined))
286
+ continue;
287
+ if (current.scope === "task" && previous.continuation?.runId !== undefined)
288
+ continue;
289
+ if (previous.inbox !== undefined && sameSession(previous.inbox.claim.actor, current))
290
+ continue;
291
+ throw new Error(`Earlier inbox entry ${previous.id} must be handled first; input order is preserved.`);
292
+ }
293
+ entry = { ...entry, inbox: { claim: { requestId, actor: current, at: now.toISOString() } } };
294
+ }
295
+ else {
296
+ if (!entry.inbox)
297
+ throw new Error("Claim the exact entry before handling and acknowledgement.");
298
+ // Ack settles already-owned handling; it never submits or retargets
299
+ // the original. The current rightful successor may settle a claim
300
+ // acquired by the exact selected Session, retaining both identities.
301
+ const claimant = entry.inbox.claim.actor;
302
+ if (isWake(entry)) {
303
+ const messageIds = new Set(entry.refs?.filter(ref => ref.type === "message").map(ref => ref.id));
304
+ for (const message of tx.listMessages(current.taskId)) {
305
+ if (messageIds.has(message.id))
306
+ assertSelectedTarget(claimant, message);
307
+ }
308
+ }
309
+ else
310
+ assertSelectedTarget(claimant, entry);
311
+ if (!isWake(entry) && current.scope === "task"
312
+ && !(current.roleName === "leader" && isLeaderSteer(entry))) {
313
+ const invalid = messageAssignmentBlocker(tx, entry);
314
+ if (invalid !== undefined)
315
+ throw new Error(`Assignment no longer authorizes this inbox disposition: ${invalid}.`);
316
+ }
317
+ // Current rightful successor may settle a prior Session's unfinished
318
+ // claim explicitly; this never replays input or discards prior evidence.
319
+ if (entry.inbox.ack) {
320
+ if (entry.inbox.ack.requestId !== requestId || entry.inbox.ack.reason !== reason
321
+ || !sameSession(entry.inbox.ack.actor, current))
322
+ throw new Error("Conflicting acknowledgement; original disposition retained.");
323
+ return receipt(entry);
324
+ }
325
+ const inbox = { ...entry.inbox,
326
+ ack: { requestId, actor: current, at: now.toISOString(), reason: reason } };
327
+ if (isWake(entry)) {
328
+ const mailbox = tx.getWorkMailbox(target(current));
329
+ // stop/start removes operational mailboxes, not durable claim
330
+ // ownership. Settle that receipt without touching a later batch.
331
+ if (mailbox?.processing?.owner === `agent-inbox:${entry.id}`
332
+ && mailbox.processing.batchId === `inbox:${entry.id}`) {
333
+ tx.saveWorkMailbox(completeProcessing(mailbox, mailbox.processing.batchId));
334
+ }
335
+ }
336
+ entry = { ...entry, inbox };
337
+ }
338
+ }
339
+ save(tx, current, entry);
340
+ return receipt(entry);
341
+ });
342
+ if (ids.length === 1)
343
+ return apply(ids[0]);
344
+ return { results: ids.map(id => {
345
+ try {
346
+ return { id, ok: true, result: apply(id) };
347
+ }
348
+ catch (error) {
349
+ return { id, ok: false, reason: error instanceof Error ? error.message : String(error) };
350
+ }
351
+ }) };
352
+ }
@@ -1,5 +1,6 @@
1
1
  import { usageError } from "../errors/cliError.js";
2
2
  import { loadYuiSkillContexts } from "../context/roleSessionContext.js";
3
+ import { checkCurrentSkills } from "../context/currentSkills.js";
3
4
  const ROLE_LAUNCH_CONTEXT_OPTIONS = new Set([
4
5
  "--workspace",
5
6
  "--description",
@@ -34,6 +35,7 @@ export function validateConfiguredRoleSkills(yuiHome, skillIds) {
34
35
  throw usageError(`Configured Skill id is invalid: ${id}.`);
35
36
  }
36
37
  try {
38
+ checkCurrentSkills(yuiHome, [id]);
37
39
  loadYuiSkillContexts(yuiHome, [id]);
38
40
  }
39
41
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
+ import { taskMessageInboxReceipt } from "../message/taskInboxOwnership.js";
3
4
  import { agentExecutionComponentLabel } from "../agent/executionComponents.js";
4
5
  import { assertLeaderArchiveAdmission } from "../task/leaderArchiveAuthority.js";
5
6
  import { createRun, runExecutionObservation, runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
@@ -1631,9 +1632,25 @@ function taskMessageCommand(args, store, options) {
1631
1632
  const message = store.transaction((tx) => {
1632
1633
  assertTaskDeliveryAuthority(tx, options.environment, ref.taskId);
1633
1634
  const current = tx.listMessages(ref.taskId).find((entry) => entry.id === ref.localId);
1634
- if (current?.recipient?.ownerRunId === undefined || current.continuation?.runId !== undefined) {
1635
+ if (current?.inbox?.ack !== undefined || current?.recipient?.ownerRunId === undefined || current.continuation?.runId !== undefined) {
1635
1636
  throw usageError("Only an unassigned pending Message can be explicitly handed off.");
1636
1637
  }
1638
+ if (current.inbox !== undefined) {
1639
+ const actor = current.inbox.claim.actor;
1640
+ for (const runId of new Set([actor.runId, current.recipient.ownerRunId])) {
1641
+ const handler = runId === undefined ? null : tx.getRun(ref.taskId, runId);
1642
+ const observation = handler === null ? undefined : runExecutionObservation(handler, tx.getTaskRoleSessionSet(ref.taskId, handler.roleName)?.providerBinding, tx.listEvents(ref.taskId));
1643
+ if (handler === null || handler.status === "active"
1644
+ || handler.result?.failureReason === "delivery-unknown"
1645
+ || handler.result?.provider === undefined
1646
+ || runId === actor.runId && handler.result.provider.conversationId !== actor.nativeSessionId
1647
+ || !["completed", "failed", "cancelled"].includes(observation?.delivery ?? "")) {
1648
+ throw usageError("The original and current inbox handlers must have exact stopped native execution before explicit handoff.");
1649
+ }
1650
+ }
1651
+ // Preserve the original claim: the new authorized Assignment must
1652
+ // inspect prior effects and ack explicitly, never auto-replay the input.
1653
+ }
1637
1654
  const recipient = resolveMessageRecipient(tx, ref.taskId, requiredOption(parsed.options, "--to"), {
1638
1655
  ...(current.recipient.workItemId === undefined ? {} : { workItemId: current.recipient.workItemId }),
1639
1656
  ...(current.recipient.reviewRoundId === undefined ? {} : { reviewRoundId: current.recipient.reviewRoundId })
@@ -1853,15 +1870,23 @@ function steerTaskInput(taskId, input, store, options) {
1853
1870
  // and idempotent, so the Host's later settlement fold promotes it to a proven
1854
1871
  // terminal and never rewinds it. An idempotent replay above already returned
1855
1872
  // the saved Message, so this records the attempt exactly once.
1856
- store.transaction((tx) => {
1873
+ const admitted = store.transaction((tx) => {
1857
1874
  const saved = tx.listMessages(result.task.id).find((entry) => entry.id === result.message.id);
1858
1875
  const requestId = saved?.inputControl?.requestId;
1859
- if (saved === undefined || requestId === undefined)
1860
- return;
1876
+ // The recipient may claim between Message persistence and control
1877
+ // admission. A Leader window owns its referenced originals even after ack;
1878
+ // never re-submit them through a delayed steer.
1879
+ if (saved === undefined || requestId === undefined || taskMessageInboxReceipt(tx, saved) !== undefined)
1880
+ return false;
1861
1881
  tx.updateMessage(result.task.id, recordTaskMessageControlOutcome(saved, {
1862
1882
  requestId, receiptId, outcome: "pending", observedAt: clock(options)
1863
1883
  }));
1884
+ return true;
1864
1885
  });
1886
+ if (!admitted) {
1887
+ return output(`Steer message ${result.message.id} saved but not delivered (TARGET_CHANGED: inbox handling owns the input).\n`, { taskId: result.task.id, message: messageReceipt(result.message),
1888
+ steer: { state: "not-steered", code: "TARGET_CHANGED", detail: "Inbox handling already owns the original input." } });
1889
+ }
1865
1890
  return {
1866
1891
  kind: "input-steer",
1867
1892
  taskId: result.task.id,
@@ -2407,6 +2432,9 @@ function registerInterruptThen(store, taskId, roleName, thenMessageRef, target,
2407
2432
  }
2408
2433
  // A Message that already delivered, or was already ruled undeliverable, is not
2409
2434
  // a fresh input to reuse; reusing it would replay or resurrect a settled fact.
2435
+ if (taskMessageInboxReceipt(store, message) !== undefined) {
2436
+ return { code: "TARGET_CHANGED", detail: `Message ${thenMessageRef} is owned by active inbox handling.` };
2437
+ }
2410
2438
  if (message.continuation?.runId !== undefined) {
2411
2439
  return { code: "TARGET_CHANGED", detail: `Message ${thenMessageRef} was already delivered by ${message.continuation.runId}.` };
2412
2440
  }