@zq-silk/yui 2.1.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 (102) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +24 -3
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli.js +23 -3078
  5. package/dist/commands/taskCommands.js +79 -21
  6. package/dist/commands/taskIntegrationCommands.js +3 -1
  7. package/dist/commands/taskUpstreamCommands.js +3 -1
  8. package/dist/context/runContextPack.js +132 -5
  9. package/dist/context/sourceRunContext.js +4 -2
  10. package/dist/context/taskContext.js +6 -1
  11. package/dist/controlPlaneCli.js +3092 -0
  12. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  13. package/dist/controller/jobControl.js +21 -0
  14. package/dist/executor/agentExecutor.js +1 -1
  15. package/dist/executor/effectiveLaunch.js +18 -5
  16. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  17. package/dist/integration/gitIntegrationService.js +50 -15
  18. package/dist/message/messageContinuation.js +7 -2
  19. package/dist/nativeAgent/agent.js +176 -74
  20. package/dist/nativeAgent/cliDemo.js +37 -0
  21. package/dist/nativeAgent/codingTools.js +11 -0
  22. package/dist/nativeAgent/commandTool.js +215 -0
  23. package/dist/nativeAgent/compactionDemo.js +158 -0
  24. package/dist/nativeAgent/composition.js +71 -0
  25. package/dist/nativeAgent/context/budget.js +44 -0
  26. package/dist/nativeAgent/context/index.js +339 -0
  27. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  28. package/dist/nativeAgent/demo.js +12 -1
  29. package/dist/nativeAgent/evaluation/cases.js +38 -0
  30. package/dist/nativeAgent/evaluation/checks.js +91 -0
  31. package/dist/nativeAgent/evaluation/demo.js +19 -0
  32. package/dist/nativeAgent/evaluation/files.js +54 -0
  33. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  34. package/dist/nativeAgent/evaluation/index.js +239 -0
  35. package/dist/nativeAgent/executionOwner.js +209 -0
  36. package/dist/nativeAgent/filePatterns.js +170 -0
  37. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  38. package/dist/nativeAgent/index.js +13 -0
  39. package/dist/nativeAgent/interaction/cli.js +358 -0
  40. package/dist/nativeAgent/interaction/contracts.js +1 -0
  41. package/dist/nativeAgent/interaction/index.js +3 -0
  42. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  43. package/dist/nativeAgent/interaction/renderer.js +34 -0
  44. package/dist/nativeAgent/localSafety.js +258 -0
  45. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  46. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  47. package/dist/nativeAgent/model/errors.js +47 -0
  48. package/dist/nativeAgent/model/gateway.js +423 -0
  49. package/dist/nativeAgent/model/index.js +7 -0
  50. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  51. package/dist/nativeAgent/model/protocols.js +19 -0
  52. package/dist/nativeAgent/model/responses.js +263 -0
  53. package/dist/nativeAgent/model/types.js +1 -0
  54. package/dist/nativeAgent/model/wire.js +73 -0
  55. package/dist/nativeAgent/observability/index.js +220 -0
  56. package/dist/nativeAgent/product/catalog.js +82 -0
  57. package/dist/nativeAgent/product/config.js +295 -0
  58. package/dist/nativeAgent/product/facts.js +30 -0
  59. package/dist/nativeAgent/product/index.js +62 -0
  60. package/dist/nativeAgent/product/location.js +44 -0
  61. package/dist/nativeAgent/product/runtime.js +276 -0
  62. package/dist/nativeAgent/product/storage.js +49 -0
  63. package/dist/nativeAgent/product/tools.js +47 -0
  64. package/dist/nativeAgent/product/transport.js +54 -0
  65. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  66. package/dist/nativeAgent/searchTools.js +305 -0
  67. package/dist/nativeAgent/session/backends.js +293 -0
  68. package/dist/nativeAgent/session/catalog.js +97 -0
  69. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  70. package/dist/nativeAgent/session/contracts.js +1 -0
  71. package/dist/nativeAgent/session/format.js +269 -0
  72. package/dist/nativeAgent/session/index.js +5 -0
  73. package/dist/nativeAgent/session/location.js +36 -0
  74. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  75. package/dist/nativeAgent/session/store.js +248 -0
  76. package/dist/nativeAgent/textTools.js +270 -133
  77. package/dist/nativeAgent/toolManager/executor.js +290 -0
  78. package/dist/nativeAgent/toolManager/index.js +4 -0
  79. package/dist/nativeAgent/validation.js +2 -2
  80. package/dist/task/taskAuthority.js +56 -0
  81. package/dist/web/assets/client/app.js +64 -3
  82. package/dist/web/assets/client/detail.js +6 -6
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/overview.js +12 -9
  85. package/dist/web/assets/client/sidebar.js +31 -2
  86. package/dist/web/assets/shell.js +2 -1
  87. package/dist/web/assets/styles/components.js +2 -0
  88. package/dist/web/assets/styles/layout.js +11 -4
  89. package/dist/web/assets/styles/responsive.js +12 -4
  90. package/dist/web/assets/styles/views.js +16 -13
  91. package/docs/agent-result-consumption.md +16 -0
  92. package/docs/agent-result-consumption.zh-CN.md +13 -0
  93. package/docs/examples/agent-offline.mjs +194 -0
  94. package/docs/native-agent.md +283 -0
  95. package/docs/release-workflow.md +47 -9
  96. package/docs/release-workflow.zh-CN.md +36 -6
  97. package/docs/roles-and-configuration.md +32 -0
  98. package/docs/roles-and-configuration.zh-CN.md +26 -0
  99. package/package.json +1 -1
  100. package/skills/yui-leader/SKILL.md +9 -0
  101. package/skills/yui-reviewer/SKILL.md +5 -0
  102. package/skills/yui-runtime/SKILL.md +7 -0
@@ -1,74 +1,154 @@
1
+ import { createToolExecutor } from './toolManager/index.js';
2
+ import { createContextBuilder, ContextBuildError } from './context/index.js';
1
3
  import { AgentFault, history, limits, response, size, snapshot, validOutcome } from './validation.js';
2
4
  const failed = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
3
5
  export function createAgent(options) {
4
- const tools = new Map(options.tools.map(t => [t.definition.name, t]));
5
- if (tools.size !== options.tools.length || [...tools.keys()].some(name => !name.trim())) {
6
- throw new Error('Tool names must be unique and nonempty');
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');
7
9
  }
8
- const definitions = snapshot(options.tools.map(t => t.definition));
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 });
9
26
  return {
10
27
  async runTurn(input) {
11
28
  const scope = { sessionId: input.sessionId, turnId: input.turnId };
12
29
  const signal = input.signal ?? new AbortController().signal;
30
+ const { maxSteps, input: userInput } = input;
13
31
  const messages = [];
14
32
  const events = [];
33
+ const contextReports = [];
15
34
  let steps = 0;
16
35
  let reason = 'error';
17
36
  let error;
18
- let sinkFailed = false;
37
+ let recording = recorder
38
+ ? { status: 'recorded', lastRecordedSeq: 0 } : { status: 'memory', lastRecordedSeq: 0 };
39
+ let recordingFailed = false;
40
+ const observerErrors = [];
19
41
  const emit = async (data) => {
20
- const event = snapshot({ ...scope, seq: events.length + 1, data });
42
+ let event = snapshot({ ...scope, seq: events.length + 1, data });
21
43
  events.push(event);
22
- if (options.onEvent && !sinkFailed) {
44
+ if (recorder && !recordingFailed) {
23
45
  try {
24
- await options.onEvent(event);
46
+ await recorder.record(event);
47
+ recording = { status: 'recorded', lastRecordedSeq: event.seq };
25
48
  }
26
- catch {
27
- sinkFailed = true;
28
- error = { code: 'event_sink_failed', message: 'Event sink rejected a fact; inspect in-memory evidence' };
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) });
29
76
  }
30
77
  }
31
78
  };
32
- const append = async (message, step) => {
79
+ const append = async (message, step, settlement) => {
33
80
  const saved = snapshot(message);
34
81
  messages.push(saved);
35
- await emit({ type: 'message_appended', step, message: 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 } : {}) });
36
85
  };
37
86
  await emit({ type: 'turn_started' });
38
87
  try {
39
88
  if (!scope.sessionId?.trim() || !scope.turnId?.trim()
40
- || !Number.isSafeInteger(input.maxSteps) || input.maxSteps < 1 || typeof input.input !== 'string'
41
- || size({ role: 'user', content: input.input }) > limits.messageBytes) {
89
+ || !Number.isSafeInteger(maxSteps) || maxSteps < 1 || typeof userInput !== 'string'
90
+ || size({ role: 'user', content: userInput }) > limits.messageBytes) {
42
91
  throw new AgentFault('invalid_input', 'Nonempty identities, bounded input and positive integer maxSteps required');
43
92
  }
44
- const prior = history(input.history ?? []);
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
+ }
45
99
  const usedIds = new Set(prior.flatMap(m => m.role === 'assistant' ? m.toolCalls.map(c => c.id) : []));
46
- await append({ role: 'user', content: input.input });
47
- while (!sinkFailed) {
100
+ await append({ role: 'user', content: userInput });
101
+ while (!recordingFailed) {
48
102
  if (signal.aborted) {
49
103
  reason = 'cancelled';
50
104
  break;
51
105
  }
52
- if (steps >= input.maxSteps) {
106
+ if (steps >= maxSteps) {
53
107
  reason = 'budget_exhausted';
54
108
  break;
55
109
  }
56
110
  const all = [...prior, ...messages];
57
- if (size(all) > limits.historyBytes)
58
- throw new AgentFault('context_limit', 'History exceeds context byte limit');
59
111
  steps++;
60
112
  await emit({ type: 'step_started', step: steps });
61
113
  try {
62
- if (sinkFailed)
114
+ if (recordingFailed)
63
115
  break;
64
116
  if (signal.aborted) {
65
117
  reason = 'cancelled';
66
118
  break;
67
119
  }
68
- const request = snapshot({ ...scope, step: steps, messages: all, tools: definitions });
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
+ }
69
149
  let raw;
70
150
  try {
71
- raw = await options.provider.complete(request, signal);
151
+ raw = await provider.complete(request, signal);
72
152
  }
73
153
  catch (cause) {
74
154
  if (signal.aborted) {
@@ -89,56 +169,85 @@ export function createAgent(options) {
89
169
  }
90
170
  await append({ role: 'assistant', content: model.content, toolCalls: model.calls }, steps);
91
171
  let uncertain = false;
92
- for (const call of model.calls) {
93
- usedIds.add(call.id);
94
- let outcome;
95
- if (sinkFailed || uncertain || signal.aborted) {
96
- outcome = failed(signal.aborted ? 'cancelled_before_start' : 'not_started', 'Tool was not started');
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');
97
182
  }
98
- else {
99
- const tool = tools.get(call.name);
100
- if (!tool)
101
- outcome = failed('unknown_tool', `Unknown tool: ${call.name}`);
102
- else {
103
- // Validation has no effects. Unexpected execution failures conservatively
104
- // preserve uncertainty and stop the batch; never retry a write.
105
- try {
106
- const invalid = tool.validate(call.arguments);
107
- if (invalid) {
108
- outcome = { ok: false, error: invalid };
109
- if (!validOutcome(outcome))
110
- outcome = failed('tool_protocol', 'Invalid tool validation result');
111
- }
112
- else {
113
- await emit({ type: 'tool_started', step: steps, toolCallId: call.id, name: call.name });
114
- if (sinkFailed || signal.aborted)
115
- outcome = failed('not_started', 'Tool was not started');
116
- else {
117
- try {
118
- outcome = await tool.execute(call.arguments, { ...scope, step: steps, toolCallId: call.id }, signal);
119
- if (!validOutcome(outcome))
120
- outcome = failed('tool_protocol', 'Invalid tool result', 'unknown');
121
- }
122
- catch (cause) {
123
- outcome = failed('tool_exception', cause instanceof Error ? cause.message : String(cause), 'unknown');
124
- }
125
- }
126
- }
127
- }
128
- catch {
129
- outcome = failed('tool_validation', 'Tool validation failed');
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');
130
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
+ };
131
230
  }
132
231
  }
133
- if (!validOutcome(outcome))
134
- outcome = failed('tool_protocol', 'Invalid tool failure result', 'unknown');
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'));
135
243
  if (!outcome.ok && outcome.error.effect === 'unknown') {
136
244
  uncertain = true;
137
245
  error ??= { code: outcome.error.code, message: outcome.error.message };
138
246
  }
139
- await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome }, steps);
247
+ if (!receipt)
248
+ await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome }, steps);
140
249
  }
141
- if (uncertain || sinkFailed) {
250
+ if (uncertain || recordingFailed || batchFailed) {
142
251
  reason = 'error';
143
252
  break;
144
253
  }
@@ -157,17 +266,10 @@ export function createAgent(options) {
157
266
  message: cause instanceof Error ? cause.message : String(cause) };
158
267
  reason = 'error';
159
268
  }
160
- if (sinkFailed)
269
+ if (recordingFailed)
161
270
  reason = 'error';
162
- // If the sink rejects the terminal itself, replace that one in-memory
163
- // terminal with the truthful failure. Never recursively retry the sink.
164
271
  await emit({ type: 'turn_ended', reason, ...(error ? { errorCode: error.code } : {}) });
165
- if (sinkFailed) {
166
- reason = 'error';
167
- const last = events[events.length - 1];
168
- events[events.length - 1] = snapshot({ ...last, data: { type: 'turn_ended', reason, errorCode: error.code } });
169
- }
170
- return snapshot({ ...scope, reason, messages, events, steps, ...(error ? { error } : {}) });
272
+ return snapshot({ ...scope, reason, messages, events, steps, recording, observerErrors, contextReports, ...(error ? { error } : {}) });
171
273
  },
172
274
  };
173
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
+ }