@zq-silk/yui 2.0.0 → 2.2.0

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.md +4 -0
  2. package/dist/cli/commandCatalog.js +318 -289
  3. package/dist/cli/commandDiscovery.js +72 -0
  4. package/dist/cli/completion.js +22 -227
  5. package/dist/cli/dynamicCompletion.js +12 -6
  6. package/dist/cli/invocationAuthority.js +2 -2
  7. package/dist/cli/invocationRouter.js +18 -7
  8. package/dist/cli.js +23 -3030
  9. package/dist/commands/capabilityCommands.js +5 -5
  10. package/dist/commands/globalRoleCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +90 -21
  12. package/dist/commands/taskIntegrationCommands.js +3 -1
  13. package/dist/commands/taskUpstreamCommands.js +3 -1
  14. package/dist/context/runContextPack.js +132 -5
  15. package/dist/context/sourceRunContext.js +4 -2
  16. package/dist/context/taskContext.js +12 -3
  17. package/dist/controlPlaneCli.js +3092 -0
  18. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  19. package/dist/controller/jobControl.js +21 -0
  20. package/dist/executor/agentExecutor.js +1 -1
  21. package/dist/executor/effectiveLaunch.js +18 -5
  22. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  23. package/dist/integration/gitIntegrationService.js +50 -15
  24. package/dist/kernel/accessAssessment.js +1 -0
  25. package/dist/kernel/builtinCapabilities.js +28 -1
  26. package/dist/kernel/capabilityRegistry.js +64 -19
  27. package/dist/message/messageContinuation.js +7 -2
  28. package/dist/nativeAgent/agent.js +275 -0
  29. package/dist/nativeAgent/cliDemo.js +37 -0
  30. package/dist/nativeAgent/codingTools.js +11 -0
  31. package/dist/nativeAgent/commandTool.js +215 -0
  32. package/dist/nativeAgent/compactionDemo.js +158 -0
  33. package/dist/nativeAgent/composition.js +71 -0
  34. package/dist/nativeAgent/context/budget.js +44 -0
  35. package/dist/nativeAgent/context/index.js +339 -0
  36. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  37. package/dist/nativeAgent/contracts.js +1 -0
  38. package/dist/nativeAgent/demo.js +40 -0
  39. package/dist/nativeAgent/evaluation/cases.js +38 -0
  40. package/dist/nativeAgent/evaluation/checks.js +91 -0
  41. package/dist/nativeAgent/evaluation/demo.js +19 -0
  42. package/dist/nativeAgent/evaluation/files.js +54 -0
  43. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  44. package/dist/nativeAgent/evaluation/index.js +239 -0
  45. package/dist/nativeAgent/executionOwner.js +209 -0
  46. package/dist/nativeAgent/filePatterns.js +170 -0
  47. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  48. package/dist/nativeAgent/index.js +16 -0
  49. package/dist/nativeAgent/interaction/cli.js +358 -0
  50. package/dist/nativeAgent/interaction/contracts.js +1 -0
  51. package/dist/nativeAgent/interaction/index.js +3 -0
  52. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  53. package/dist/nativeAgent/interaction/renderer.js +34 -0
  54. package/dist/nativeAgent/localSafety.js +258 -0
  55. package/dist/nativeAgent/mockProvider.js +43 -0
  56. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  57. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  58. package/dist/nativeAgent/model/errors.js +47 -0
  59. package/dist/nativeAgent/model/gateway.js +423 -0
  60. package/dist/nativeAgent/model/index.js +7 -0
  61. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  62. package/dist/nativeAgent/model/protocols.js +19 -0
  63. package/dist/nativeAgent/model/responses.js +263 -0
  64. package/dist/nativeAgent/model/types.js +1 -0
  65. package/dist/nativeAgent/model/wire.js +73 -0
  66. package/dist/nativeAgent/observability/index.js +220 -0
  67. package/dist/nativeAgent/product/catalog.js +82 -0
  68. package/dist/nativeAgent/product/config.js +295 -0
  69. package/dist/nativeAgent/product/facts.js +30 -0
  70. package/dist/nativeAgent/product/index.js +62 -0
  71. package/dist/nativeAgent/product/location.js +44 -0
  72. package/dist/nativeAgent/product/runtime.js +276 -0
  73. package/dist/nativeAgent/product/storage.js +49 -0
  74. package/dist/nativeAgent/product/tools.js +47 -0
  75. package/dist/nativeAgent/product/transport.js +54 -0
  76. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  77. package/dist/nativeAgent/searchTools.js +305 -0
  78. package/dist/nativeAgent/session/backends.js +293 -0
  79. package/dist/nativeAgent/session/catalog.js +97 -0
  80. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  81. package/dist/nativeAgent/session/contracts.js +1 -0
  82. package/dist/nativeAgent/session/format.js +269 -0
  83. package/dist/nativeAgent/session/index.js +5 -0
  84. package/dist/nativeAgent/session/location.js +36 -0
  85. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  86. package/dist/nativeAgent/session/store.js +248 -0
  87. package/dist/nativeAgent/textTools.js +293 -0
  88. package/dist/nativeAgent/toolManager/executor.js +290 -0
  89. package/dist/nativeAgent/toolManager/index.js +4 -0
  90. package/dist/nativeAgent/validation.js +95 -0
  91. package/dist/plugins/pluginService.js +98 -8
  92. package/dist/resources/projectResourceService.js +23 -1
  93. package/dist/runtime/managedIdentity.js +6 -0
  94. package/dist/surface/surfaceContributions.js +3 -0
  95. package/dist/task/taskAuthority.js +61 -10
  96. package/dist/web/assets/assetManifest.js +38 -22
  97. package/dist/web/assets/client/api.js +115 -0
  98. package/dist/web/assets/client/app.js +615 -862
  99. package/dist/web/assets/client/components.js +323 -897
  100. package/dist/web/assets/client/detail.js +333 -0
  101. package/dist/web/assets/client/dock.js +225 -0
  102. package/dist/web/assets/client/dom.js +55 -3
  103. package/dist/web/assets/client/evidence.js +172 -0
  104. package/dist/web/assets/client/format.js +25 -14
  105. package/dist/web/assets/client/forms.js +230 -0
  106. package/dist/web/assets/client/i18n.js +1024 -791
  107. package/dist/web/assets/client/overview.js +118 -0
  108. package/dist/web/assets/client/records.js +63 -0
  109. package/dist/web/assets/client/sections.js +313 -0
  110. package/dist/web/assets/client/sidebar.js +146 -0
  111. package/dist/web/assets/client/theme.js +61 -22
  112. package/dist/web/assets/icons.js +39 -0
  113. package/dist/web/assets/shell.js +125 -118
  114. package/dist/web/assets/styles/base.js +41 -0
  115. package/dist/web/assets/styles/components.js +169 -0
  116. package/dist/web/assets/styles/layout.js +56 -77
  117. package/dist/web/assets/styles/markdown.js +16 -24
  118. package/dist/web/assets/styles/responsive.js +41 -44
  119. package/dist/web/assets/styles/tokens.js +65 -89
  120. package/dist/web/assets/styles/views.js +292 -0
  121. package/dist/web/webServer.js +2 -1
  122. package/docs/agent-result-consumption.md +16 -0
  123. package/docs/agent-result-consumption.zh-CN.md +13 -0
  124. package/docs/examples/agent-offline.mjs +194 -0
  125. package/docs/native-agent.md +283 -0
  126. package/docs/release-workflow.md +61 -0
  127. package/docs/release-workflow.zh-CN.md +46 -0
  128. package/docs/roles-and-configuration.md +32 -0
  129. package/docs/roles-and-configuration.zh-CN.md +26 -0
  130. package/package.json +1 -1
  131. package/skills/yui-leader/SKILL.md +9 -0
  132. package/skills/yui-reviewer/SKILL.md +5 -0
  133. package/skills/yui-runtime/SKILL.md +33 -0
  134. package/dist/web/assets/client/taskSummary.js +0 -350
  135. package/dist/web/assets/client/taskSurface.js +0 -616
  136. package/dist/web/assets/client/view.js +0 -608
  137. package/dist/web/assets/styles/cards.js +0 -247
  138. package/dist/web/assets/styles/widgets.js +0 -168
@@ -275,9 +275,14 @@ export function prepareMessageContinuations(store, taskId, now, roleName) {
275
275
  const { endedAt: _endedAt, failure: _failure, ...current } = round;
276
276
  return { ...current, status: "running", reviewerRunId: runId };
277
277
  })();
278
- const priorMessages = baseline.resources.filter((entry) => entry.ref.store === "task-message").slice(-16);
278
+ const messages = baseline.resources.filter((entry) => entry.ref.store === "task-message");
279
+ const priorMessages = [
280
+ ...messages.filter(entry => entry.ref.evidenceOf === "dispatch-required"),
281
+ ...messages.filter(entry => entry.ref.evidenceOf !== "dispatch-required").slice(-16)
282
+ ];
279
283
  const source = sourceRunContextValue(previous);
280
- const resources = [...baseline.resources.filter((entry) => entry.ref.store !== "task-message" && entry.ref.store !== "source-run"
284
+ const resources = [...baseline.resources.filter((entry) => entry.ref.store !== "task-message"
285
+ && (entry.ref.store !== "source-run" || entry.ref.evidenceOf !== undefined)
281
286
  && !(continuingRound !== null && entry.ref.store === "review-round" && entry.ref.refId === continuingRound.id)),
282
287
  ...(continuingRound === null ? [] : [{ ref: { layer: "L3", store: "review-round",
283
288
  refId: continuingRound.id, revision: now.toISOString(), digest: contextContentDigest(continuingRound),
@@ -0,0 +1,275 @@
1
+ import { createToolExecutor } from './toolManager/index.js';
2
+ import { createContextBuilder, ContextBuildError } from './context/index.js';
3
+ import { AgentFault, history, limits, response, size, snapshot, validOutcome } from './validation.js';
4
+ const failed = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
5
+ export function createAgent(options) {
6
+ if (!options.provider || typeof options.provider.complete !== 'function'
7
+ || (options.tools === undefined) === (options.toolExecutor === undefined)) {
8
+ throw new Error('A provider and exactly one of tools or toolExecutor are required');
9
+ }
10
+ // tools is an explicitly preauthorized, prebound capability set. This lease
11
+ // owns no resources and does not pretend to narrow those tools' authority.
12
+ const executor = options.toolExecutor ?? createToolExecutor({
13
+ tools: options.tools,
14
+ environment: { async acquire() { return { value: undefined, async release() { } }; } },
15
+ permission: { async check() { return { allowed: true }; } },
16
+ });
17
+ const definitions = snapshot(executor.definitions);
18
+ if (typeof executor.executeBatch !== 'function' || !Array.isArray(definitions)
19
+ || definitions.some(d => !d || typeof d.name !== 'string' || !d.name.trim())
20
+ || new Set(definitions.map(d => d.name)).size !== definitions.length) {
21
+ throw new Error('An executor with unique nonempty tool definitions is required');
22
+ }
23
+ const { provider, recorder, observer } = options;
24
+ const contextBuilder = options.contextBuilder ?? createContextBuilder();
25
+ const contextBudget = snapshot(options.contextBudget ?? { capacity: limits.historyBytes, reserveOutput: 0 });
26
+ return {
27
+ async runTurn(input) {
28
+ const scope = { sessionId: input.sessionId, turnId: input.turnId };
29
+ const signal = input.signal ?? new AbortController().signal;
30
+ const { maxSteps, input: userInput } = input;
31
+ const messages = [];
32
+ const events = [];
33
+ const contextReports = [];
34
+ let steps = 0;
35
+ let reason = 'error';
36
+ let error;
37
+ let recording = recorder
38
+ ? { status: 'recorded', lastRecordedSeq: 0 } : { status: 'memory', lastRecordedSeq: 0 };
39
+ let recordingFailed = false;
40
+ const observerErrors = [];
41
+ const emit = async (data) => {
42
+ let event = snapshot({ ...scope, seq: events.length + 1, data });
43
+ events.push(event);
44
+ if (recorder && !recordingFailed) {
45
+ try {
46
+ await recorder.record(event);
47
+ recording = { status: 'recorded', lastRecordedSeq: event.seq };
48
+ }
49
+ catch (cause) {
50
+ recordingFailed = true;
51
+ recording = { status: 'failed', lastRecordedSeq: recording.lastRecordedSeq, failedSeq: event.seq };
52
+ error = { code: 'recording_failed',
53
+ message: `Required recording failed: ${cause instanceof Error ? cause.message : String(cause)}; inspect recorder before recovery` };
54
+ }
55
+ }
56
+ // A failed terminal write has unknown external persistence. Publish
57
+ // exactly one truthful local terminal to observers and the caller.
58
+ if (data.type === 'turn_ended' && recordingFailed) {
59
+ reason = 'error';
60
+ event = snapshot({ ...event, data: { type: 'turn_ended', reason, errorCode: error.code } });
61
+ events[events.length - 1] = event;
62
+ }
63
+ // Observers enqueue synchronously. Never await display/telemetry delivery.
64
+ // Detach an invalid async consumer and observe its rejection without
65
+ // misrepresenting notification as transport or persistence confirmation.
66
+ if (observer && !observerErrors.length) {
67
+ try {
68
+ const returned = observer.observe(event);
69
+ if (returned && typeof returned.then === 'function') {
70
+ void Promise.resolve(returned).catch(() => { });
71
+ throw new Error('Observer must return synchronously; enqueue asynchronous transport in the consumer');
72
+ }
73
+ }
74
+ catch (cause) {
75
+ observerErrors.push({ seq: event.seq, message: cause instanceof Error ? cause.message : String(cause) });
76
+ }
77
+ }
78
+ };
79
+ const append = async (message, step, settlement) => {
80
+ const saved = snapshot(message);
81
+ messages.push(saved);
82
+ const evidence = settlement && { started: settlement.started, status: settlement.status,
83
+ cancellationRequested: settlement.cancellationRequested, cleanup: settlement.cleanup };
84
+ await emit({ type: 'message_appended', step, message: saved, ...(evidence ? { settlement: evidence } : {}) });
85
+ };
86
+ await emit({ type: 'turn_started' });
87
+ try {
88
+ if (!scope.sessionId?.trim() || !scope.turnId?.trim()
89
+ || !Number.isSafeInteger(maxSteps) || maxSteps < 1 || typeof userInput !== 'string'
90
+ || size({ role: 'user', content: userInput }) > limits.messageBytes) {
91
+ throw new AgentFault('invalid_input', 'Nonempty identities, bounded input and positive integer maxSteps required');
92
+ }
93
+ // Canonical history can exceed the model request budget. The context
94
+ // builder may project it, but must not hide unresolved effects or IDs.
95
+ const prior = history(input.history ?? [], Infinity);
96
+ if (prior.some(m => m.role === 'tool' && !m.outcome.ok && m.outcome.error.effect === 'unknown')) {
97
+ throw new AgentFault('unresolved_effect', 'History contains an unknown tool effect; reconcile it explicitly before continuing');
98
+ }
99
+ const usedIds = new Set(prior.flatMap(m => m.role === 'assistant' ? m.toolCalls.map(c => c.id) : []));
100
+ await append({ role: 'user', content: userInput });
101
+ while (!recordingFailed) {
102
+ if (signal.aborted) {
103
+ reason = 'cancelled';
104
+ break;
105
+ }
106
+ if (steps >= maxSteps) {
107
+ reason = 'budget_exhausted';
108
+ break;
109
+ }
110
+ const all = [...prior, ...messages];
111
+ steps++;
112
+ await emit({ type: 'step_started', step: steps });
113
+ try {
114
+ if (recordingFailed)
115
+ break;
116
+ if (signal.aborted) {
117
+ reason = 'cancelled';
118
+ break;
119
+ }
120
+ const source = snapshot({ ...scope, step: steps, messages: all, tools: definitions });
121
+ let request;
122
+ try {
123
+ const built = await contextBuilder.build({ ...options.contextRetention, ...input.context,
124
+ request: source, budget: contextBudget }, signal);
125
+ if (signal.aborted) {
126
+ reason = 'cancelled';
127
+ break;
128
+ }
129
+ if (built.request.sessionId !== scope.sessionId || built.request.turnId !== scope.turnId
130
+ || built.request.step !== steps || JSON.stringify(built.request.tools) !== JSON.stringify(definitions)) {
131
+ throw new Error('Context builder changed execution identity or tool definitions');
132
+ }
133
+ request = snapshot({ ...built.request, messages: history(built.request.messages, Infinity) });
134
+ contextReports.push(snapshot({ step: steps, status: 'prepared', report: built.report }));
135
+ }
136
+ catch (cause) {
137
+ if (cause instanceof ContextBuildError && cause.report) {
138
+ contextReports.push(snapshot({ step: steps, status: 'failed', report: cause.report }));
139
+ }
140
+ if (signal.aborted) {
141
+ reason = 'cancelled';
142
+ break;
143
+ }
144
+ throw new AgentFault('context_error', cause instanceof Error ? cause.message : String(cause));
145
+ }
146
+ if (size(request) > limits.historyBytes) {
147
+ throw new AgentFault('context_limit', 'Model request exceeds context byte limit');
148
+ }
149
+ let raw;
150
+ try {
151
+ raw = await provider.complete(request, signal);
152
+ }
153
+ catch (cause) {
154
+ if (signal.aborted) {
155
+ reason = 'cancelled';
156
+ break;
157
+ }
158
+ throw new AgentFault('provider_error', cause instanceof Error ? cause.message : String(cause));
159
+ }
160
+ if (signal.aborted) {
161
+ reason = 'cancelled';
162
+ break;
163
+ }
164
+ const model = response(raw, usedIds);
165
+ if (model.kind === 'final') {
166
+ await append({ role: 'assistant', content: model.content, toolCalls: [] }, steps);
167
+ reason = signal.aborted ? 'cancelled' : 'completed';
168
+ break;
169
+ }
170
+ await append({ role: 'assistant', content: model.content, toolCalls: model.calls }, steps);
171
+ let uncertain = false;
172
+ const settled = new Map();
173
+ let batchFailed = false;
174
+ const acceptSettlement = async (settlement) => {
175
+ const call = model.calls[settled.size];
176
+ if (!call || settlement.identity.sessionId !== scope.sessionId
177
+ || settlement.identity.turnId !== scope.turnId || settlement.identity.step !== steps
178
+ || settlement.identity.toolCallId !== call.id || settlement.identity.name !== call.name
179
+ || !validOutcome(settlement.outcome) || typeof settlement.started !== 'boolean'
180
+ || !['not_acquired', 'released', 'failed', 'acquire_failed'].includes(settlement.cleanup?.status)) {
181
+ throw new Error('Executor returned an invalid or out-of-order settlement');
182
+ }
183
+ const saved = snapshot(settlement);
184
+ settled.set(call.id, saved);
185
+ await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome: saved.outcome }, steps, saved);
186
+ };
187
+ if (!recordingFailed && !signal.aborted) {
188
+ try {
189
+ const batch = await executor.executeBatch({
190
+ calls: model.calls, scope: snapshot({ ...scope, step: steps }), signal,
191
+ async beforeExecute(identity) {
192
+ const call = model.calls[settled.size];
193
+ if (!call || identity.sessionId !== scope.sessionId || identity.turnId !== scope.turnId
194
+ || identity.step !== steps || identity.toolCallId !== call.id || identity.name !== call.name)
195
+ throw new Error('Executor intent identity does not match the next call');
196
+ if (recordingFailed)
197
+ throw new Error('Turn no longer permits a new tool effect');
198
+ if (signal.aborted)
199
+ return;
200
+ await emit({ type: 'tool_started', step: steps, toolCallId: call.id, name: call.name });
201
+ if (recordingFailed)
202
+ throw new Error('Required intent recording stopped execution');
203
+ // The executor checks cancellation after this barrier. A
204
+ // successful write followed by cancellation is not a failed write.
205
+ },
206
+ async afterExecute(settlement) {
207
+ await acceptSettlement(settlement);
208
+ if (recordingFailed)
209
+ throw new Error('Required settlement recording failed; do not start further effects');
210
+ },
211
+ });
212
+ if (batch.results.length !== model.calls.length)
213
+ throw new Error('Executor omitted settlements');
214
+ for (const settlement of batch.results) {
215
+ const recorded = settled.get(settlement.identity.toolCallId);
216
+ if (recorded) {
217
+ if (JSON.stringify(recorded) !== JSON.stringify(settlement))
218
+ throw new Error('Executor changed a recorded settlement');
219
+ }
220
+ else
221
+ await acceptSettlement(settlement);
222
+ }
223
+ batchFailed = !!batch.recordingError || (batch.stopped !== null && batch.stopped !== 'cancelled');
224
+ if (batchFailed) {
225
+ const unknown = batch.results.find(r => !r.outcome.ok && r.outcome.error.effect === 'unknown')?.outcome;
226
+ error ??= unknown && !unknown.ok ? unknown.error : {
227
+ code: batch.recordingError?.code ?? batch.stopped,
228
+ message: batch.recordingError?.message ?? `Tool batch stopped: ${batch.stopped}; inspect settlement evidence`,
229
+ };
230
+ }
231
+ }
232
+ catch (cause) {
233
+ batchFailed = true;
234
+ error ??= { code: 'tool_protocol', message: cause instanceof Error ? cause.message : String(cause) };
235
+ }
236
+ }
237
+ for (const call of model.calls) {
238
+ usedIds.add(call.id);
239
+ const receipt = settled.get(call.id);
240
+ const outcome = receipt?.outcome ?? (batchFailed
241
+ ? failed('tool_protocol', 'Executor did not return a confirmed settlement', 'unknown')
242
+ : failed(signal.aborted ? 'cancelled_before_start' : 'not_started', 'Tool was not started'));
243
+ if (!outcome.ok && outcome.error.effect === 'unknown') {
244
+ uncertain = true;
245
+ error ??= { code: outcome.error.code, message: outcome.error.message };
246
+ }
247
+ if (!receipt)
248
+ await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome }, steps);
249
+ }
250
+ if (uncertain || recordingFailed || batchFailed) {
251
+ reason = 'error';
252
+ break;
253
+ }
254
+ if (signal.aborted) {
255
+ reason = 'cancelled';
256
+ break;
257
+ }
258
+ }
259
+ finally {
260
+ await emit({ type: 'step_ended', step: steps });
261
+ }
262
+ }
263
+ }
264
+ catch (cause) {
265
+ error ??= { code: cause instanceof AgentFault ? cause.code : 'internal_error',
266
+ message: cause instanceof Error ? cause.message : String(cause) };
267
+ reason = 'error';
268
+ }
269
+ if (recordingFailed)
270
+ reason = 'error';
271
+ await emit({ type: 'turn_ended', reason, ...(error ? { errorCode: error.code } : {}) });
272
+ return snapshot({ ...scope, reason, messages, events, steps, recording, observerErrors, contextReports, ...(error ? { error } : {}) });
273
+ },
274
+ };
275
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { createMockProvider, createTextTools } from './index.js';
5
+ import { createMemoryDemoSessions, openCli } from './interaction/index.js';
6
+ // This program owns its disposable files and execution service. The CLI itself
7
+ // only detaches; this owner explicitly cancels and settles turns on process exit.
8
+ let root;
9
+ let sessions;
10
+ let cli;
11
+ let stopping = false;
12
+ const interrupt = () => { stopping = true; cli?.close(); };
13
+ process.on('SIGINT', interrupt);
14
+ process.on('SIGTERM', interrupt);
15
+ try {
16
+ root = await mkdtemp(path.join(tmpdir(), 'independent-agent-cli-'));
17
+ await writeFile(path.join(root, 'input.txt'), 'Disposable mock CLI example.\n');
18
+ let draw = 0;
19
+ sessions = createMemoryDemoSessions({
20
+ provider: createMockProvider({ toolCallProbability: 0.5, random: () => [0.1, 0.2, 0.9][draw++ % 3] }),
21
+ tools: createTextTools({ root }),
22
+ });
23
+ cli = await openCli({ sessions, input: process.stdin, output: process.stdout });
24
+ if (stopping)
25
+ cli.close();
26
+ const end = await cli.done;
27
+ if (end.reason === 'display-error' || end.reason === 'input-error')
28
+ process.exitCode = 1;
29
+ }
30
+ finally {
31
+ process.off('SIGINT', interrupt);
32
+ process.off('SIGTERM', interrupt);
33
+ cli?.close();
34
+ await sessions?.close();
35
+ if (root)
36
+ await rm(root, { recursive: true, force: true });
37
+ }
@@ -0,0 +1,11 @@
1
+ import { createTextTools } from './textTools.js';
2
+ import { createSearchTools } from './searchTools.js';
3
+ import { createCommandTool } from './commandTool.js';
4
+ /** Explicit assembly only; each returned Tool can instead be selected or replaced independently. */
5
+ export function createCodingTools(options) {
6
+ return [
7
+ ...createTextTools({ ...options.text, root: options.root }),
8
+ ...createSearchTools({ ...options.search, root: options.root }),
9
+ ...(options.command ? [createCommandTool({ ...options.command, root: options.root })] : []),
10
+ ];
11
+ }
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { realpathSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ const failure = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
5
+ const inRoot = (root, cwd) => {
6
+ const relative = path.relative(root, cwd);
7
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
8
+ };
9
+ const bounded = (value, maximum, label) => {
10
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
11
+ throw new Error(`${label} must be an integer in [1, ${maximum}]`);
12
+ }
13
+ return value;
14
+ };
15
+ /**
16
+ * Controlled POSIX commands only, not an OS sandbox. Commands can access paths
17
+ * outside cwd, perform external effects, or escape the process group themselves.
18
+ * Callers must authorize the executable/arguments and supply a deliberate env.
19
+ */
20
+ export function createCommandTool(options) {
21
+ if (process.platform === 'win32')
22
+ throw new Error('Command tool requires POSIX process groups');
23
+ if (!options || typeof options.root !== 'string' || !path.isAbsolute(options.root)) {
24
+ throw new Error('An explicit absolute workspace root is required');
25
+ }
26
+ const root = realpathSync(options.root);
27
+ if (!statSync(root).isDirectory())
28
+ throw new Error('Workspace root must be a directory');
29
+ if (!options.env || typeof options.env !== 'object' || Array.isArray(options.env)
30
+ || Object.entries(options.env).some(([key, value]) => !key || /[=\0]/.test(key) || typeof value !== 'string' || value.includes('\0'))) {
31
+ throw new Error('An explicit string environment without NUL or invalid keys is required');
32
+ }
33
+ const env = { ...options.env };
34
+ const timeoutMs = bounded(options.timeoutMs ?? 10_000, 60_000, 'timeoutMs');
35
+ const maxOutputBytes = bounded(options.maxOutputBytes ?? 64 * 1024, 64 * 1024, 'maxOutputBytes');
36
+ const killGraceMs = bounded(options.killGraceMs ?? 100, 1000, 'killGraceMs');
37
+ const validate = (args) => {
38
+ if (!args || typeof args !== 'object' || Array.isArray(args)
39
+ || Object.keys(args).some(key => !['command', 'argv', 'cwd'].includes(key))
40
+ || typeof args.command !== 'string' || !path.isAbsolute(args.command) || args.command.includes('\0')
41
+ || typeof args.cwd !== 'string' || !path.isAbsolute(args.cwd) || args.cwd.includes('\0')
42
+ || !Array.isArray(args.argv) || args.argv.length > 256
43
+ || args.argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))
44
+ // Evidence echoes argv. Its JSON string is encoded once more in the
45
+ // ToolOutcome: reserve room for 64KiB of worst-case control-byte output
46
+ // (7x after both JSON encodings) within the public 512KiB message bound.
47
+ || Buffer.byteLength(JSON.stringify(args)) > 16 * 1024) {
48
+ return { code: 'invalid_arguments', message: 'Expected absolute command/cwd and bounded string argv only', effect: 'none' };
49
+ }
50
+ if (!inRoot(root, path.resolve(args.cwd))) {
51
+ return { code: 'path_out_of_scope', message: 'cwd must be within the explicit workspace root', effect: 'none' };
52
+ }
53
+ return null;
54
+ };
55
+ return {
56
+ definition: {
57
+ name: 'command',
58
+ description: 'Execute an explicitly authorized POSIX executable and argv in a controlled workspace; no implicit shell or OS sandbox',
59
+ inputSchema: {
60
+ type: 'object', properties: {
61
+ command: { type: 'string', description: 'Absolute executable path' },
62
+ argv: { type: 'array', items: { type: 'string' }, maxItems: 256 },
63
+ cwd: { type: 'string', description: 'Absolute working directory within configured root' },
64
+ }, required: ['command', 'argv', 'cwd'], additionalProperties: false,
65
+ },
66
+ },
67
+ validate,
68
+ async execute(args, _scope, signal) {
69
+ const invalid = validate(args);
70
+ if (invalid)
71
+ return { ok: false, error: invalid };
72
+ if (signal.aborted)
73
+ return failure('cancelled_before_start', 'Command was not started');
74
+ const input = args;
75
+ // Copy caller-owned data before spawning; direct execute calls receive the
76
+ // same checks as calls through the Agent loop.
77
+ const command = input.command;
78
+ const argv = [...input.argv];
79
+ let cwd;
80
+ try {
81
+ cwd = realpathSync(input.cwd);
82
+ if (!inRoot(root, cwd))
83
+ return failure('path_out_of_scope', 'Resolved cwd is outside workspace root');
84
+ if (!statSync(cwd).isDirectory())
85
+ return failure('invalid_cwd', 'cwd is not a directory');
86
+ }
87
+ catch {
88
+ return failure('invalid_cwd', 'cwd cannot be resolved as an accessible directory');
89
+ }
90
+ return new Promise(resolve => {
91
+ let child;
92
+ try {
93
+ child = spawn(command, argv, { cwd, env, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
94
+ }
95
+ catch (error) {
96
+ resolve(failure('spawn_failed', `Command could not be spawned (${error.code ?? 'unknown'})`));
97
+ return;
98
+ }
99
+ let settled = false;
100
+ let started = false;
101
+ let directChildExited = false;
102
+ let exitCode = null;
103
+ let exitSignal = null;
104
+ let reason;
105
+ let processErrorCode = null;
106
+ let captured = 0;
107
+ let outputTruncated = false;
108
+ const stdout = [];
109
+ const stderr = [];
110
+ const signalErrors = [];
111
+ let grace;
112
+ let settle;
113
+ const groupState = () => {
114
+ if (!child.pid)
115
+ return 'absent';
116
+ try {
117
+ process.kill(-child.pid, 0);
118
+ return 'present';
119
+ }
120
+ catch (error) {
121
+ return error.code === 'ESRCH' ? 'absent' : 'unknown';
122
+ }
123
+ };
124
+ const signalGroup = (kind) => {
125
+ if (!child.pid)
126
+ return;
127
+ try {
128
+ process.kill(-child.pid, kind);
129
+ }
130
+ catch (error) {
131
+ const code = error.code;
132
+ if (code !== 'ESRCH')
133
+ signalErrors.push(`${kind}:${code ?? 'unknown'}`);
134
+ }
135
+ };
136
+ const finish = () => {
137
+ if (settled)
138
+ return;
139
+ settled = true;
140
+ clearTimeout(deadline);
141
+ clearTimeout(grace);
142
+ clearTimeout(settle);
143
+ signal.removeEventListener('abort', abort);
144
+ child.stdout.destroy();
145
+ child.stderr.destroy();
146
+ // A failure to observe termination must not keep the caller waiting
147
+ // forever. Keep the pid and uncertainty in the returned evidence.
148
+ child.unref();
149
+ const evidence = {
150
+ command, argv, cwd, pid: child.pid ?? null, exitCode, signal: exitSignal,
151
+ stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'),
152
+ capturedBytes: captured, outputTruncated, directChildExited,
153
+ processGroup: groupState(), signalErrors, processErrorCode,
154
+ descendantsMayHaveEscaped: true,
155
+ };
156
+ resolve(reason
157
+ ? failure(reason, JSON.stringify(evidence), started ? 'unknown' : 'none')
158
+ : { ok: true, content: JSON.stringify(evidence) });
159
+ };
160
+ const stop = (code) => {
161
+ if (settled || reason)
162
+ return;
163
+ reason = code;
164
+ signalGroup('SIGTERM');
165
+ grace = setTimeout(() => {
166
+ signalGroup('SIGKILL');
167
+ // Bound observation after escalation; never infer quiescence from a
168
+ // successful signal syscall, or wait forever on inherited pipes.
169
+ settle = setTimeout(finish, 100);
170
+ }, killGraceMs);
171
+ };
172
+ const abort = () => stop('cancelled');
173
+ const deadline = setTimeout(() => stop('timeout'), timeoutMs);
174
+ signal.addEventListener('abort', abort, { once: true });
175
+ const capture = (target, chunk) => {
176
+ const available = maxOutputBytes - captured;
177
+ if (available > 0) {
178
+ const kept = chunk.subarray(0, available);
179
+ target.push(Buffer.from(kept));
180
+ captured += kept.length;
181
+ }
182
+ if (chunk.length > available) {
183
+ outputTruncated = true;
184
+ stop('output_limit');
185
+ }
186
+ };
187
+ child.stdout.on('data', (chunk) => capture(stdout, chunk));
188
+ child.stderr.on('data', (chunk) => capture(stderr, chunk));
189
+ child.once('spawn', () => { started = true; });
190
+ child.once('error', error => {
191
+ processErrorCode = error.code ?? 'unknown';
192
+ if (started)
193
+ stop('process_error');
194
+ else {
195
+ reason = 'spawn_failed';
196
+ finish();
197
+ }
198
+ });
199
+ child.once('exit', (code, receivedSignal) => {
200
+ directChildExited = true;
201
+ exitCode = code;
202
+ exitSignal = receivedSignal;
203
+ if (!reason && groupState() !== 'absent')
204
+ stop('background_processes');
205
+ });
206
+ child.once('close', () => {
207
+ if (!reason)
208
+ finish();
209
+ });
210
+ if (signal.aborted)
211
+ abort();
212
+ });
213
+ },
214
+ };
215
+ }