@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3
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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Native task control - the trusted first-stage coordinator deciding
|
|
4
|
-
// whether ONE native background task may receive a targeted stop through a
|
|
5
|
-
// CURRENT SDK query lease. It owns no transport and invents no facts: the
|
|
6
|
-
// runner supplies three explicit trusted synchronous callbacks (live task
|
|
7
|
-
// lookup, SDK query lease lookup, durability verdict) and this module only
|
|
8
|
-
// combines their answers into one short receipt per stop request. Every
|
|
9
|
-
// non-satisfiable case is an explicit rejection code - there is
|
|
10
|
-
// deliberately NO fallback to session.abort, transport.close or stopping
|
|
11
|
-
// the parent session. Receipts carry only fixed safe codes, short detail
|
|
12
|
-
// tags and identity fields: upstream exception text is never copied into
|
|
13
|
-
// a receipt (truncation is not redaction), a callback that throws or
|
|
14
|
-
// answers asynchronously resolves an explicit unavailable receipt instead
|
|
15
|
-
// of rejecting the stop() promise, and the task a lookup returns is
|
|
16
|
-
// verified against the requested ref (taskId, ownerRunId, taskRef, SDK
|
|
17
|
-
// session, kind/type) before any SDK call, so one broken lookup can never
|
|
18
|
-
// widen into stopping a different task. This is not yet a product stop
|
|
19
|
-
// API: runner, Service and API wiring is a later stage; unit tests use
|
|
20
|
-
// fixtures and are not a real-SDK proof.
|
|
21
|
-
|
|
22
|
-
const { createTaskScope, createNativeTaskRef, sameTaskScope } = require('./task-identity');
|
|
23
|
-
|
|
24
|
-
const CONTROLLED_KINDS = new Set(['agent', 'local_bash']);
|
|
25
|
-
// Mirrors TASK_KIND_BY_TYPE in the registry: a bound task's kind is
|
|
26
|
-
// derived from its type there, so an inconsistent kind/type pair here can
|
|
27
|
-
// only come from a broken lookup and must never reach the SDK.
|
|
28
|
-
const KIND_BY_TYPE = new Map([['local_agent', 'agent'], ['local_bash', 'local_bash']]);
|
|
29
|
-
const STOPPABLE_STATUSES = new Set(['pending', 'running']);
|
|
30
|
-
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
|
31
|
-
const MAX_REQUEST_ID_LENGTH = 200;
|
|
32
|
-
|
|
33
|
-
function id(value) { return typeof value === 'string' && value.trim() !== '' ? value : null; }
|
|
34
|
-
function freeze(receipt) { return Object.freeze(receipt); }
|
|
35
|
-
function own(object, key) { return Object.prototype.hasOwnProperty.call(object, key); }
|
|
36
|
-
function isThenable(value) {
|
|
37
|
-
return !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// The canonical owner tuple embedded in an ownerRunId. Anything that does
|
|
41
|
-
// not parse back to ['sdk-launch', 1, sdkSessionId, parent, toolUseId]
|
|
42
|
-
// cannot be identity-verified against a live task.
|
|
43
|
-
function ownerTuple(ownerRunId) {
|
|
44
|
-
let tuple = null;
|
|
45
|
-
try { tuple = JSON.parse(ownerRunId); } catch (error) { return null; }
|
|
46
|
-
if (!Array.isArray(tuple) || tuple.length !== 5) return null;
|
|
47
|
-
if (tuple[0] !== 'sdk-launch' || tuple[1] !== 1) return null;
|
|
48
|
-
if (!id(tuple[2])) return null;
|
|
49
|
-
return tuple;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Verifies that the object a trusted lookup returned really IS the task
|
|
53
|
-
// the caller asked to stop. Own (not inherited) identity fields, matching
|
|
54
|
-
// taskId / ownerRunId / taskRef, the SDK session embedded in the owner
|
|
55
|
-
// tuple and kind/type compatibility are all required; any doubt becomes a
|
|
56
|
-
// fixed detail tag and the stop is refused before the SDK is touched.
|
|
57
|
-
function targetMismatch(ref, task) {
|
|
58
|
-
for (const key of ['taskId', 'sdkSessionId', 'ownerRunId', 'taskRef', 'taskKind', 'taskType']) {
|
|
59
|
-
if (!own(task, key)) return 'task-shape';
|
|
60
|
-
}
|
|
61
|
-
if (task.taskId !== ref.taskId) return 'task-id';
|
|
62
|
-
if (task.ownerRunId !== ref.ownerRunId) return 'owner-run';
|
|
63
|
-
const tuple = ownerTuple(ref.ownerRunId);
|
|
64
|
-
if (!tuple) return 'owner-run';
|
|
65
|
-
if (task.sdkSessionId !== tuple[2]) return 'sdk-session';
|
|
66
|
-
const taskRef = task.taskRef;
|
|
67
|
-
if (!taskRef || typeof taskRef !== 'object' || Array.isArray(taskRef)) return 'task-ref';
|
|
68
|
-
for (const key of ['kind', 'storeScope', 'contextId', 'parentSessionId', 'ownerRunId', 'taskId']) {
|
|
69
|
-
if (!own(taskRef, key)) return 'task-ref';
|
|
70
|
-
}
|
|
71
|
-
if (taskRef.kind !== ref.kind || taskRef.taskId !== ref.taskId || taskRef.ownerRunId !== ref.ownerRunId) return 'task-ref';
|
|
72
|
-
if (taskRef.storeScope !== ref.storeScope || taskRef.contextId !== ref.contextId || taskRef.parentSessionId !== ref.parentSessionId) return 'task-ref';
|
|
73
|
-
if (KIND_BY_TYPE.get(task.taskType) !== task.taskKind) return 'kind-type';
|
|
74
|
-
if (id(task.toolUseId) && tuple[4] !== task.toolUseId) return 'tool-use';
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function createNativeTaskControl({ scope, runtimeEpoch, getLiveTask, getLease, isDurable } = {}) {
|
|
79
|
-
const fixedScope = createTaskScope(scope);
|
|
80
|
-
if (!fixedScope) throw new Error('createNativeTaskControl requires a fixed TaskScope');
|
|
81
|
-
if (!id(runtimeEpoch)) throw new Error('createNativeTaskControl requires a runtimeEpoch string');
|
|
82
|
-
// No default allow: every trusted callback must be supplied explicitly.
|
|
83
|
-
if (typeof getLiveTask !== 'function' || typeof getLease !== 'function' || typeof isDurable !== 'function') {
|
|
84
|
-
throw new Error('createNativeTaskControl requires explicit getLiveTask, getLease and isDurable functions');
|
|
85
|
-
}
|
|
86
|
-
const epoch = runtimeEpoch;
|
|
87
|
-
const frozenScope = Object.freeze({ storeScope: fixedScope.storeScope, contextId: fixedScope.contextId, parentSessionId: fixedScope.parentSessionId });
|
|
88
|
-
// requestId -> { payload, promise }: an id is bound to the full request
|
|
89
|
-
// payload (fixed scope + canonical ref + epoch). The same id replays the
|
|
90
|
-
// same result - in flight or settled, including deterministic failure
|
|
91
|
-
// receipts - and a different payload under the same id is a diagnosed
|
|
92
|
-
// conflict, never a second dispatch.
|
|
93
|
-
const requests = new Map();
|
|
94
|
-
// (epoch, scope, ref, leaseId) -> send receipt promise: the same lease
|
|
95
|
-
// and ref receive at most ONE SDK stopTask call whatever the requestIds;
|
|
96
|
-
// both 'requested' and 'unknown' outcomes stay cached. A later real
|
|
97
|
-
// terminal is only ever produced by independent SDK facts.
|
|
98
|
-
const sentStops = new Map();
|
|
99
|
-
|
|
100
|
-
function rejected(code, extra) {
|
|
101
|
-
return freeze(Object.assign({ ok: false, code }, extra || {}));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Runs one trusted synchronous callback under the protocol contract. A
|
|
105
|
-
// throw, or a thenable answer that breaks the synchronous contract,
|
|
106
|
-
// becomes a fixed unavailable receipt: the exception text is dropped,
|
|
107
|
-
// stop() still resolves, the SDK is never called, and the answer is
|
|
108
|
-
// cached per requestId like any deterministic result - never swallowed
|
|
109
|
-
// into ok or stopped.
|
|
110
|
-
function syncAnswer(invoke, code, identity) {
|
|
111
|
-
let value;
|
|
112
|
-
try {
|
|
113
|
-
value = invoke();
|
|
114
|
-
} catch (error) {
|
|
115
|
-
return { failed: rejected(code, identity) };
|
|
116
|
-
}
|
|
117
|
-
if (isThenable(value)) return { failed: rejected(code, identity) };
|
|
118
|
-
return { value };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function execute(ref, requestId) {
|
|
122
|
-
const lookedUp = syncAnswer(() => getLiveTask(ref), 'live-lookup-failed', { requestId });
|
|
123
|
-
if (lookedUp.failed) return lookedUp.failed;
|
|
124
|
-
const task = lookedUp.value;
|
|
125
|
-
if (!task || typeof task !== 'object' || Array.isArray(task)) return rejected('task-not-live', { requestId });
|
|
126
|
-
const identity = { requestId, taskId: task.taskId, sdkSessionId: task.sdkSessionId };
|
|
127
|
-
if (task.association === 'main-command') return rejected('task-main-command', identity);
|
|
128
|
-
if (task.association !== 'bound') return rejected('task-unassigned', identity);
|
|
129
|
-
if (task.typeConflict === true) return rejected('task-type-conflict', identity);
|
|
130
|
-
if (!CONTROLLED_KINDS.has(task.taskKind)) return rejected('task-kind-unknown', identity);
|
|
131
|
-
const mismatch = targetMismatch(ref, task);
|
|
132
|
-
if (mismatch) return rejected('task-target-mismatch', { requestId, taskId: ref.taskId, detail: mismatch });
|
|
133
|
-
if (TERMINAL_STATUSES.has(task.status)) {
|
|
134
|
-
// A terminal established by real SDK facts needs no SDK call at all.
|
|
135
|
-
return freeze(Object.assign({ ok: true, outcome: 'already-terminal', status: task.status, statusSource: task.statusSource || null, stopTaskSent: false }, identity));
|
|
136
|
-
}
|
|
137
|
-
if (!STOPPABLE_STATUSES.has(task.status)) return rejected('task-status-unknown', identity);
|
|
138
|
-
const durability = syncAnswer(() => isDurable(ref, task), 'durability-unavailable', identity);
|
|
139
|
-
if (durability.failed) return durability.failed;
|
|
140
|
-
if (durability.value !== true) return rejected('durability-insufficient', identity);
|
|
141
|
-
const leased = syncAnswer(() => getLease(task.sdkSessionId), 'lease-unavailable', identity);
|
|
142
|
-
if (leased.failed) return leased.failed;
|
|
143
|
-
const lease = leased.value;
|
|
144
|
-
if (!lease || typeof lease !== 'object' || Array.isArray(lease)) return rejected('lease-not-found', identity);
|
|
145
|
-
const leaseId = id(lease.leaseId);
|
|
146
|
-
if (lease.active !== true) return rejected('lease-inactive', Object.assign({ leaseId }, identity));
|
|
147
|
-
if (id(lease.sdkSessionId) !== task.sdkSessionId) return rejected('lease-session-mismatch', Object.assign({ leaseId }, identity));
|
|
148
|
-
if (!leaseId) return rejected('lease-invalid', identity);
|
|
149
|
-
// Lease identity fields must be the lease's own data: an object that
|
|
150
|
-
// only inherits sdkSessionId/leaseId/active/query from a prototype (or
|
|
151
|
-
// smuggles them through a promise-like shape) is never a real lease.
|
|
152
|
-
for (const key of ['leaseId', 'active', 'sdkSessionId', 'query']) {
|
|
153
|
-
if (!own(lease, key)) return rejected('lease-invalid', Object.assign({ leaseId }, identity));
|
|
154
|
-
}
|
|
155
|
-
if (!lease.query || typeof lease.query !== 'object' || typeof lease.query.stopTask !== 'function') {
|
|
156
|
-
return rejected('lease-handle-invalid', Object.assign({ leaseId }, identity));
|
|
157
|
-
}
|
|
158
|
-
const stopKey = JSON.stringify([epoch, frozenScope.storeScope, frozenScope.contextId, frozenScope.parentSessionId, ref.ownerRunId, ref.taskId, leaseId]);
|
|
159
|
-
let entry = sentStops.get(stopKey);
|
|
160
|
-
if (!entry) {
|
|
161
|
-
// Exactly one SDK call per lease+ref, invoked as a method so `this`
|
|
162
|
-
// is the real query object. The RPC resolving only proves the
|
|
163
|
-
// request left: the task status itself is never rewritten here, and
|
|
164
|
-
// a rejection is kept as an unknown outcome with NO upstream error
|
|
165
|
-
// text - receipts expose only this module's fixed codes.
|
|
166
|
-
const send = (async () => {
|
|
167
|
-
try {
|
|
168
|
-
await lease.query.stopTask(task.taskId);
|
|
169
|
-
return freeze({ ok: true, outcome: 'requested', stopTaskSent: true });
|
|
170
|
-
} catch (error) {
|
|
171
|
-
return freeze({ ok: false, outcome: 'unknown', code: 'stop-rejected', stopTaskSent: true });
|
|
172
|
-
}
|
|
173
|
-
})();
|
|
174
|
-
entry = { promise: send };
|
|
175
|
-
sentStops.set(stopKey, entry);
|
|
176
|
-
const base = await entry.promise;
|
|
177
|
-
return freeze(Object.assign({ leaseId, deduped: false }, base, identity));
|
|
178
|
-
}
|
|
179
|
-
const base = await entry.promise;
|
|
180
|
-
return freeze(Object.assign({ leaseId, deduped: true }, base, identity));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Receipts always resolve (never throw): every unsatisfiable case is a
|
|
184
|
-
// short receipt with an explicit code and no upstream text, and even
|
|
185
|
-
// hostile property getters on the stop arguments cannot turn stop() into
|
|
186
|
-
// a rejected promise. Callers get a fresh copy per call, so mutating a
|
|
187
|
-
// returned receipt cannot rewrite the stored one.
|
|
188
|
-
function stop(args) {
|
|
189
|
-
let ref = null;
|
|
190
|
-
let requestId = null;
|
|
191
|
-
try {
|
|
192
|
-
if (!args || typeof args !== 'object') return Promise.resolve(rejected('request-invalid', { detail: 'stop-args' }));
|
|
193
|
-
ref = createNativeTaskRef(args.taskRef);
|
|
194
|
-
if (!ref) return Promise.resolve(rejected('request-invalid', { detail: 'taskRef' }));
|
|
195
|
-
requestId = args.requestId;
|
|
196
|
-
if (!id(requestId) || requestId.length > MAX_REQUEST_ID_LENGTH) return Promise.resolve(rejected('request-invalid', { detail: 'requestId' }));
|
|
197
|
-
if (!sameTaskScope(ref, frozenScope)) return Promise.resolve(rejected('ref-scope-mismatch', { requestId }));
|
|
198
|
-
if (!id(args.runtimeEpoch)) return Promise.resolve(rejected('request-invalid', { requestId, detail: 'runtimeEpoch' }));
|
|
199
|
-
if (args.runtimeEpoch !== epoch) return Promise.resolve(rejected('epoch-mismatch', { requestId }));
|
|
200
|
-
} catch (error) {
|
|
201
|
-
return Promise.resolve(rejected('request-invalid', { detail: 'stop-args' }));
|
|
202
|
-
}
|
|
203
|
-
const payload = JSON.stringify([frozenScope.storeScope, frozenScope.contextId, frozenScope.parentSessionId, ref.ownerRunId, ref.taskId, epoch]);
|
|
204
|
-
const existing = requests.get(requestId);
|
|
205
|
-
if (existing) {
|
|
206
|
-
if (existing.payload !== payload) return Promise.resolve(rejected('request-conflict', { requestId }));
|
|
207
|
-
return existing.promise.then(receipt => ({ ...receipt }));
|
|
208
|
-
}
|
|
209
|
-
const promise = execute(ref, requestId);
|
|
210
|
-
requests.set(requestId, { payload, promise });
|
|
211
|
-
return promise.then(receipt => ({ ...receipt }));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return { stop };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
module.exports = { createNativeTaskControl };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native task control - the trusted first-stage coordinator deciding
|
|
4
|
+
// whether ONE native background task may receive a targeted stop through a
|
|
5
|
+
// CURRENT SDK query lease. It owns no transport and invents no facts: the
|
|
6
|
+
// runner supplies three explicit trusted synchronous callbacks (live task
|
|
7
|
+
// lookup, SDK query lease lookup, durability verdict) and this module only
|
|
8
|
+
// combines their answers into one short receipt per stop request. Every
|
|
9
|
+
// non-satisfiable case is an explicit rejection code - there is
|
|
10
|
+
// deliberately NO fallback to session.abort, transport.close or stopping
|
|
11
|
+
// the parent session. Receipts carry only fixed safe codes, short detail
|
|
12
|
+
// tags and identity fields: upstream exception text is never copied into
|
|
13
|
+
// a receipt (truncation is not redaction), a callback that throws or
|
|
14
|
+
// answers asynchronously resolves an explicit unavailable receipt instead
|
|
15
|
+
// of rejecting the stop() promise, and the task a lookup returns is
|
|
16
|
+
// verified against the requested ref (taskId, ownerRunId, taskRef, SDK
|
|
17
|
+
// session, kind/type) before any SDK call, so one broken lookup can never
|
|
18
|
+
// widen into stopping a different task. This is not yet a product stop
|
|
19
|
+
// API: runner, Service and API wiring is a later stage; unit tests use
|
|
20
|
+
// fixtures and are not a real-SDK proof.
|
|
21
|
+
|
|
22
|
+
const { createTaskScope, createNativeTaskRef, sameTaskScope } = require('./task-identity');
|
|
23
|
+
|
|
24
|
+
const CONTROLLED_KINDS = new Set(['agent', 'local_bash']);
|
|
25
|
+
// Mirrors TASK_KIND_BY_TYPE in the registry: a bound task's kind is
|
|
26
|
+
// derived from its type there, so an inconsistent kind/type pair here can
|
|
27
|
+
// only come from a broken lookup and must never reach the SDK.
|
|
28
|
+
const KIND_BY_TYPE = new Map([['local_agent', 'agent'], ['local_bash', 'local_bash']]);
|
|
29
|
+
const STOPPABLE_STATUSES = new Set(['pending', 'running']);
|
|
30
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
|
31
|
+
const MAX_REQUEST_ID_LENGTH = 200;
|
|
32
|
+
|
|
33
|
+
function id(value) { return typeof value === 'string' && value.trim() !== '' ? value : null; }
|
|
34
|
+
function freeze(receipt) { return Object.freeze(receipt); }
|
|
35
|
+
function own(object, key) { return Object.prototype.hasOwnProperty.call(object, key); }
|
|
36
|
+
function isThenable(value) {
|
|
37
|
+
return !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The canonical owner tuple embedded in an ownerRunId. Anything that does
|
|
41
|
+
// not parse back to ['sdk-launch', 1, sdkSessionId, parent, toolUseId]
|
|
42
|
+
// cannot be identity-verified against a live task.
|
|
43
|
+
function ownerTuple(ownerRunId) {
|
|
44
|
+
let tuple = null;
|
|
45
|
+
try { tuple = JSON.parse(ownerRunId); } catch (error) { return null; }
|
|
46
|
+
if (!Array.isArray(tuple) || tuple.length !== 5) return null;
|
|
47
|
+
if (tuple[0] !== 'sdk-launch' || tuple[1] !== 1) return null;
|
|
48
|
+
if (!id(tuple[2])) return null;
|
|
49
|
+
return tuple;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Verifies that the object a trusted lookup returned really IS the task
|
|
53
|
+
// the caller asked to stop. Own (not inherited) identity fields, matching
|
|
54
|
+
// taskId / ownerRunId / taskRef, the SDK session embedded in the owner
|
|
55
|
+
// tuple and kind/type compatibility are all required; any doubt becomes a
|
|
56
|
+
// fixed detail tag and the stop is refused before the SDK is touched.
|
|
57
|
+
function targetMismatch(ref, task) {
|
|
58
|
+
for (const key of ['taskId', 'sdkSessionId', 'ownerRunId', 'taskRef', 'taskKind', 'taskType']) {
|
|
59
|
+
if (!own(task, key)) return 'task-shape';
|
|
60
|
+
}
|
|
61
|
+
if (task.taskId !== ref.taskId) return 'task-id';
|
|
62
|
+
if (task.ownerRunId !== ref.ownerRunId) return 'owner-run';
|
|
63
|
+
const tuple = ownerTuple(ref.ownerRunId);
|
|
64
|
+
if (!tuple) return 'owner-run';
|
|
65
|
+
if (task.sdkSessionId !== tuple[2]) return 'sdk-session';
|
|
66
|
+
const taskRef = task.taskRef;
|
|
67
|
+
if (!taskRef || typeof taskRef !== 'object' || Array.isArray(taskRef)) return 'task-ref';
|
|
68
|
+
for (const key of ['kind', 'storeScope', 'contextId', 'parentSessionId', 'ownerRunId', 'taskId']) {
|
|
69
|
+
if (!own(taskRef, key)) return 'task-ref';
|
|
70
|
+
}
|
|
71
|
+
if (taskRef.kind !== ref.kind || taskRef.taskId !== ref.taskId || taskRef.ownerRunId !== ref.ownerRunId) return 'task-ref';
|
|
72
|
+
if (taskRef.storeScope !== ref.storeScope || taskRef.contextId !== ref.contextId || taskRef.parentSessionId !== ref.parentSessionId) return 'task-ref';
|
|
73
|
+
if (KIND_BY_TYPE.get(task.taskType) !== task.taskKind) return 'kind-type';
|
|
74
|
+
if (id(task.toolUseId) && tuple[4] !== task.toolUseId) return 'tool-use';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createNativeTaskControl({ scope, runtimeEpoch, getLiveTask, getLease, isDurable } = {}) {
|
|
79
|
+
const fixedScope = createTaskScope(scope);
|
|
80
|
+
if (!fixedScope) throw new Error('createNativeTaskControl requires a fixed TaskScope');
|
|
81
|
+
if (!id(runtimeEpoch)) throw new Error('createNativeTaskControl requires a runtimeEpoch string');
|
|
82
|
+
// No default allow: every trusted callback must be supplied explicitly.
|
|
83
|
+
if (typeof getLiveTask !== 'function' || typeof getLease !== 'function' || typeof isDurable !== 'function') {
|
|
84
|
+
throw new Error('createNativeTaskControl requires explicit getLiveTask, getLease and isDurable functions');
|
|
85
|
+
}
|
|
86
|
+
const epoch = runtimeEpoch;
|
|
87
|
+
const frozenScope = Object.freeze({ storeScope: fixedScope.storeScope, contextId: fixedScope.contextId, parentSessionId: fixedScope.parentSessionId });
|
|
88
|
+
// requestId -> { payload, promise }: an id is bound to the full request
|
|
89
|
+
// payload (fixed scope + canonical ref + epoch). The same id replays the
|
|
90
|
+
// same result - in flight or settled, including deterministic failure
|
|
91
|
+
// receipts - and a different payload under the same id is a diagnosed
|
|
92
|
+
// conflict, never a second dispatch.
|
|
93
|
+
const requests = new Map();
|
|
94
|
+
// (epoch, scope, ref, leaseId) -> send receipt promise: the same lease
|
|
95
|
+
// and ref receive at most ONE SDK stopTask call whatever the requestIds;
|
|
96
|
+
// both 'requested' and 'unknown' outcomes stay cached. A later real
|
|
97
|
+
// terminal is only ever produced by independent SDK facts.
|
|
98
|
+
const sentStops = new Map();
|
|
99
|
+
|
|
100
|
+
function rejected(code, extra) {
|
|
101
|
+
return freeze(Object.assign({ ok: false, code }, extra || {}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Runs one trusted synchronous callback under the protocol contract. A
|
|
105
|
+
// throw, or a thenable answer that breaks the synchronous contract,
|
|
106
|
+
// becomes a fixed unavailable receipt: the exception text is dropped,
|
|
107
|
+
// stop() still resolves, the SDK is never called, and the answer is
|
|
108
|
+
// cached per requestId like any deterministic result - never swallowed
|
|
109
|
+
// into ok or stopped.
|
|
110
|
+
function syncAnswer(invoke, code, identity) {
|
|
111
|
+
let value;
|
|
112
|
+
try {
|
|
113
|
+
value = invoke();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { failed: rejected(code, identity) };
|
|
116
|
+
}
|
|
117
|
+
if (isThenable(value)) return { failed: rejected(code, identity) };
|
|
118
|
+
return { value };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function execute(ref, requestId) {
|
|
122
|
+
const lookedUp = syncAnswer(() => getLiveTask(ref), 'live-lookup-failed', { requestId });
|
|
123
|
+
if (lookedUp.failed) return lookedUp.failed;
|
|
124
|
+
const task = lookedUp.value;
|
|
125
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return rejected('task-not-live', { requestId });
|
|
126
|
+
const identity = { requestId, taskId: task.taskId, sdkSessionId: task.sdkSessionId };
|
|
127
|
+
if (task.association === 'main-command') return rejected('task-main-command', identity);
|
|
128
|
+
if (task.association !== 'bound') return rejected('task-unassigned', identity);
|
|
129
|
+
if (task.typeConflict === true) return rejected('task-type-conflict', identity);
|
|
130
|
+
if (!CONTROLLED_KINDS.has(task.taskKind)) return rejected('task-kind-unknown', identity);
|
|
131
|
+
const mismatch = targetMismatch(ref, task);
|
|
132
|
+
if (mismatch) return rejected('task-target-mismatch', { requestId, taskId: ref.taskId, detail: mismatch });
|
|
133
|
+
if (TERMINAL_STATUSES.has(task.status)) {
|
|
134
|
+
// A terminal established by real SDK facts needs no SDK call at all.
|
|
135
|
+
return freeze(Object.assign({ ok: true, outcome: 'already-terminal', status: task.status, statusSource: task.statusSource || null, stopTaskSent: false }, identity));
|
|
136
|
+
}
|
|
137
|
+
if (!STOPPABLE_STATUSES.has(task.status)) return rejected('task-status-unknown', identity);
|
|
138
|
+
const durability = syncAnswer(() => isDurable(ref, task), 'durability-unavailable', identity);
|
|
139
|
+
if (durability.failed) return durability.failed;
|
|
140
|
+
if (durability.value !== true) return rejected('durability-insufficient', identity);
|
|
141
|
+
const leased = syncAnswer(() => getLease(task.sdkSessionId), 'lease-unavailable', identity);
|
|
142
|
+
if (leased.failed) return leased.failed;
|
|
143
|
+
const lease = leased.value;
|
|
144
|
+
if (!lease || typeof lease !== 'object' || Array.isArray(lease)) return rejected('lease-not-found', identity);
|
|
145
|
+
const leaseId = id(lease.leaseId);
|
|
146
|
+
if (lease.active !== true) return rejected('lease-inactive', Object.assign({ leaseId }, identity));
|
|
147
|
+
if (id(lease.sdkSessionId) !== task.sdkSessionId) return rejected('lease-session-mismatch', Object.assign({ leaseId }, identity));
|
|
148
|
+
if (!leaseId) return rejected('lease-invalid', identity);
|
|
149
|
+
// Lease identity fields must be the lease's own data: an object that
|
|
150
|
+
// only inherits sdkSessionId/leaseId/active/query from a prototype (or
|
|
151
|
+
// smuggles them through a promise-like shape) is never a real lease.
|
|
152
|
+
for (const key of ['leaseId', 'active', 'sdkSessionId', 'query']) {
|
|
153
|
+
if (!own(lease, key)) return rejected('lease-invalid', Object.assign({ leaseId }, identity));
|
|
154
|
+
}
|
|
155
|
+
if (!lease.query || typeof lease.query !== 'object' || typeof lease.query.stopTask !== 'function') {
|
|
156
|
+
return rejected('lease-handle-invalid', Object.assign({ leaseId }, identity));
|
|
157
|
+
}
|
|
158
|
+
const stopKey = JSON.stringify([epoch, frozenScope.storeScope, frozenScope.contextId, frozenScope.parentSessionId, ref.ownerRunId, ref.taskId, leaseId]);
|
|
159
|
+
let entry = sentStops.get(stopKey);
|
|
160
|
+
if (!entry) {
|
|
161
|
+
// Exactly one SDK call per lease+ref, invoked as a method so `this`
|
|
162
|
+
// is the real query object. The RPC resolving only proves the
|
|
163
|
+
// request left: the task status itself is never rewritten here, and
|
|
164
|
+
// a rejection is kept as an unknown outcome with NO upstream error
|
|
165
|
+
// text - receipts expose only this module's fixed codes.
|
|
166
|
+
const send = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
await lease.query.stopTask(task.taskId);
|
|
169
|
+
return freeze({ ok: true, outcome: 'requested', stopTaskSent: true });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return freeze({ ok: false, outcome: 'unknown', code: 'stop-rejected', stopTaskSent: true });
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
entry = { promise: send };
|
|
175
|
+
sentStops.set(stopKey, entry);
|
|
176
|
+
const base = await entry.promise;
|
|
177
|
+
return freeze(Object.assign({ leaseId, deduped: false }, base, identity));
|
|
178
|
+
}
|
|
179
|
+
const base = await entry.promise;
|
|
180
|
+
return freeze(Object.assign({ leaseId, deduped: true }, base, identity));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Receipts always resolve (never throw): every unsatisfiable case is a
|
|
184
|
+
// short receipt with an explicit code and no upstream text, and even
|
|
185
|
+
// hostile property getters on the stop arguments cannot turn stop() into
|
|
186
|
+
// a rejected promise. Callers get a fresh copy per call, so mutating a
|
|
187
|
+
// returned receipt cannot rewrite the stored one.
|
|
188
|
+
function stop(args) {
|
|
189
|
+
let ref = null;
|
|
190
|
+
let requestId = null;
|
|
191
|
+
try {
|
|
192
|
+
if (!args || typeof args !== 'object') return Promise.resolve(rejected('request-invalid', { detail: 'stop-args' }));
|
|
193
|
+
ref = createNativeTaskRef(args.taskRef);
|
|
194
|
+
if (!ref) return Promise.resolve(rejected('request-invalid', { detail: 'taskRef' }));
|
|
195
|
+
requestId = args.requestId;
|
|
196
|
+
if (!id(requestId) || requestId.length > MAX_REQUEST_ID_LENGTH) return Promise.resolve(rejected('request-invalid', { detail: 'requestId' }));
|
|
197
|
+
if (!sameTaskScope(ref, frozenScope)) return Promise.resolve(rejected('ref-scope-mismatch', { requestId }));
|
|
198
|
+
if (!id(args.runtimeEpoch)) return Promise.resolve(rejected('request-invalid', { requestId, detail: 'runtimeEpoch' }));
|
|
199
|
+
if (args.runtimeEpoch !== epoch) return Promise.resolve(rejected('epoch-mismatch', { requestId }));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return Promise.resolve(rejected('request-invalid', { detail: 'stop-args' }));
|
|
202
|
+
}
|
|
203
|
+
const payload = JSON.stringify([frozenScope.storeScope, frozenScope.contextId, frozenScope.parentSessionId, ref.ownerRunId, ref.taskId, epoch]);
|
|
204
|
+
const existing = requests.get(requestId);
|
|
205
|
+
if (existing) {
|
|
206
|
+
if (existing.payload !== payload) return Promise.resolve(rejected('request-conflict', { requestId }));
|
|
207
|
+
return existing.promise.then(receipt => ({ ...receipt }));
|
|
208
|
+
}
|
|
209
|
+
const promise = execute(ref, requestId);
|
|
210
|
+
requests.set(requestId, { payload, promise });
|
|
211
|
+
return promise.then(receipt => ({ ...receipt }));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { stop };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { createNativeTaskControl };
|