nexus-agentd 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,9 +62,22 @@ Workspace 配置保持不变。
62
62
  默认值分别是 24 小时、30 分钟和 60 秒;A2A 请求超时在每个 Agent 的编辑页单独设置,默认 60 秒、
63
63
  最大 30 分钟。
64
64
 
65
- ## Agent 协议
66
-
67
- ### ACP
65
+ ## Agent 协议
66
+
67
+ 每个任务都会附带协议级完成约束:Agent 在结束 turn 前必须处理完工作,等待用户输入或授权时必须使用
68
+ 协议请求,并提供非空最终说明或 Artifact。Gateway 只接受以下完成证明:
69
+
70
+ - ACP `session/prompt` 返回 `stopReason=end_turn`;`max_tokens`、`max_turn_requests`、拒绝和取消不会被
71
+ 误报为成功。
72
+ - A2A Task 明确进入 `COMPLETED`;如果远端直接返回 Message 而没有创建 Task,则以完整消息流结束作为
73
+ turn 边界。已经出现 Task 状态但没有终态的流会标记失败。
74
+ - Session 仍有待处理的 permission/input 请求,或者最终文本与 Artifact 都为空时,不允许进入
75
+ `completed`。
76
+
77
+ 成功的 Session 响应包含 `completion`,其中记录当前 Run ID、协议、完成来源、stop reason、最终文本和
78
+ 产物存在性以及完成时间。这个证明验证的是协议边界和结果存在性,不替代业务内容本身的语义验收。
79
+
80
+ ### ACP
68
81
 
69
82
  | Driver | 默认入口 |
70
83
  |---|---|
@@ -199,9 +212,11 @@ POST /v1/sessions/:id/cancel
199
212
  GET /v1/sessions/:id/events
200
213
  ```
201
214
 
202
- `/v1/meta` 和 Session 响应包含 Gateway `instanceId`,客户端可识别进程重启。授权与输入通过精确的
215
+ `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
216
+ `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
203
217
  `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
204
- 释放 Agent 进程并移除内存 Session。
218
+ 释放 Agent 整个进程组并移除内存 Session。Gateway 停止时会先终止 Session,再在有限宽限期后关闭残留
219
+ HTTP/SSE 连接,避免长连接或 Agent 孙进程阻塞服务重启。
205
220
 
206
221
  API Key 的 Agent scope 在 Agent inventory、Session 创建和后续 Session 操作上都会检查;Session
207
222
  还绑定创建它的 Key,其他 Key 即使拥有同一 Agent scope 也不能读取或控制该 Session。
@@ -14,6 +14,9 @@ export declare class A2AClientRuntime implements AgentSessionRuntime {
14
14
  private activeController?;
15
15
  private readonly artifactCache;
16
16
  private readonly emittedMessages;
17
+ private sawTaskStatus;
18
+ private sawAgentMessage;
19
+ private completionProof?;
17
20
  constructor(config: AgentdA2AConfig, sink: AgentSessionSink, promptTimeoutMs: number);
18
21
  start(): Promise<void>;
19
22
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { Role, TaskState } from '@a2a-js/sdk';
3
3
  import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory } from '@a2a-js/sdk/client';
4
+ import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
4
5
  export async function probeA2AAgent(id, config) {
5
6
  const startedAt = Date.now();
6
7
  if (config.enabled === false)
@@ -47,6 +48,9 @@ export class A2AClientRuntime {
47
48
  activeController;
48
49
  artifactCache = new Map();
49
50
  emittedMessages = new Set();
51
+ sawTaskStatus = false;
52
+ sawAgentMessage = false;
53
+ completionProof;
50
54
  constructor(config, sink, promptTimeoutMs) {
51
55
  this.config = config;
52
56
  this.sink = sink;
@@ -76,6 +80,9 @@ export class A2AClientRuntime {
76
80
  if (this.disposed)
77
81
  throw new Error('A2A runtime is disposed');
78
82
  this.prompting = true;
83
+ this.sawTaskStatus = false;
84
+ this.sawAgentMessage = false;
85
+ this.completionProof = undefined;
79
86
  this.sink.clearPending();
80
87
  this.sink.setState('running');
81
88
  const controller = new AbortController();
@@ -89,8 +96,22 @@ export class A2AClientRuntime {
89
96
  })) {
90
97
  this.processResponse(response);
91
98
  }
92
- if (this.sink.state === 'running')
93
- this.sink.setState('completed');
99
+ if (this.sink.state === 'running') {
100
+ if (this.completionProof) {
101
+ this.sink.completeTurn(this.completionProof);
102
+ }
103
+ else if (!this.sawTaskStatus && this.sawAgentMessage) {
104
+ this.sink.completeTurn({
105
+ source: 'a2a_message_stream',
106
+ stopReason: 'message_stream_end'
107
+ });
108
+ }
109
+ else {
110
+ this.sink.setState('failed', this.sawTaskStatus
111
+ ? 'A2A stream ended without a terminal task status.'
112
+ : 'A2A stream ended without an agent result.');
113
+ }
114
+ }
94
115
  }
95
116
  catch (error) {
96
117
  if (this.sink.state !== 'canceled') {
@@ -156,6 +177,12 @@ export class A2AClientRuntime {
156
177
  filename: '',
157
178
  mediaType: 'text/plain'
158
179
  },
180
+ {
181
+ content: { $case: 'text', value: TURN_COMPLETION_CONTRACT },
182
+ metadata: undefined,
183
+ filename: '',
184
+ mediaType: 'text/plain'
185
+ },
159
186
  ...attachments.map((attachment) => ({
160
187
  content: {
161
188
  $case: 'raw',
@@ -210,6 +237,7 @@ export class A2AClientRuntime {
210
237
  processMessage(message) {
211
238
  if (!message || message.role !== Role.ROLE_AGENT)
212
239
  return;
240
+ this.sawAgentMessage = true;
213
241
  this.captureIds(message.taskId, message.contextId);
214
242
  if (message.messageId && this.emittedMessages.has(message.messageId))
215
243
  return;
@@ -222,10 +250,18 @@ export class A2AClientRuntime {
222
250
  content: { type: 'text', text }
223
251
  });
224
252
  }
253
+ message.parts.forEach((part, index) => {
254
+ const artifact = artifactFromPart(part, `message:${message.messageId || 'agent'}:${index}`);
255
+ if (!artifact)
256
+ return;
257
+ this.sink.addArtifact(artifact);
258
+ this.sink.emit('artifact', artifact);
259
+ });
225
260
  }
226
261
  processStatus(status) {
227
262
  if (!status)
228
263
  return;
264
+ this.sawTaskStatus = true;
229
265
  this.processMessage(status.message);
230
266
  const statusText = textParts(status.message?.parts || []).join('\n').trim();
231
267
  switch (status.state) {
@@ -235,7 +271,10 @@ export class A2AClientRuntime {
235
271
  break;
236
272
  case TaskState.TASK_STATE_COMPLETED:
237
273
  this.sink.clearPending();
238
- this.sink.setState('completed');
274
+ this.completionProof = {
275
+ source: 'a2a_task_status',
276
+ stopReason: 'completed'
277
+ };
239
278
  break;
240
279
  case TaskState.TASK_STATE_CANCELED:
241
280
  this.sink.clearPending();
@@ -349,6 +388,36 @@ function artifactFromA2A(artifact) {
349
388
  metadata: artifact.metadata || undefined
350
389
  };
351
390
  }
391
+ function artifactFromPart(part, id) {
392
+ switch (part.content?.$case) {
393
+ case 'raw':
394
+ return {
395
+ id,
396
+ name: part.filename || id,
397
+ filename: part.filename || undefined,
398
+ mediaType: part.mediaType || undefined,
399
+ bytesBase64: Buffer.from(part.content.value).toString('base64')
400
+ };
401
+ case 'url':
402
+ return {
403
+ id,
404
+ name: part.filename || part.content.value || id,
405
+ filename: part.filename || undefined,
406
+ mediaType: part.mediaType || undefined,
407
+ url: part.content.value || undefined
408
+ };
409
+ case 'data':
410
+ return {
411
+ id,
412
+ name: part.filename || id,
413
+ filename: part.filename || undefined,
414
+ mediaType: part.mediaType || undefined,
415
+ data: part.content.value
416
+ };
417
+ default:
418
+ return undefined;
419
+ }
420
+ }
352
421
  function textParts(parts) {
353
422
  return parts.flatMap((part) => part.content?.$case === 'text' && part.content.value ? [part.content.value] : []);
354
423
  }
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
+ import { terminateProcessTree } from '../process-tree.js';
8
+ import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
7
9
  export class AcpProcessRuntime {
8
10
  driver;
9
11
  sink;
@@ -60,7 +62,7 @@ export class AcpProcessRuntime {
60
62
  },
61
63
  clientInfo: {
62
64
  name: 'nexus-agentd',
63
- version: '0.1.4'
65
+ version: '0.2.1'
64
66
  }
65
67
  });
66
68
  if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
@@ -94,14 +96,21 @@ export class AcpProcessRuntime {
94
96
  if (this.sink.state === 'canceled' || this.sink.state === 'failed') {
95
97
  return;
96
98
  }
97
- if (response.stopReason === 'cancelled') {
98
- this.sink.setState('canceled');
99
- }
100
- else if (response.stopReason === 'refusal') {
101
- this.sink.setState('failed', 'ACP agent refused the prompt');
102
- }
103
- else {
104
- this.sink.setState('completed');
99
+ switch (response.stopReason) {
100
+ case 'end_turn':
101
+ this.sink.completeTurn({
102
+ source: 'acp_prompt_response',
103
+ stopReason: response.stopReason
104
+ });
105
+ break;
106
+ case 'cancelled':
107
+ this.sink.setState('canceled');
108
+ break;
109
+ case 'refusal':
110
+ this.sink.setState('failed', 'ACP agent refused the prompt');
111
+ break;
112
+ default:
113
+ this.sink.setState('failed', `ACP turn stopped before completion: ${response.stopReason}`);
105
114
  }
106
115
  }
107
116
  catch (error) {
@@ -216,15 +225,17 @@ export class AcpProcessRuntime {
216
225
  }
217
226
  this.connection?.close();
218
227
  this.connection = undefined;
228
+ const child = this.process;
229
+ this.process = undefined;
230
+ if (child) {
231
+ await terminateProcessTree(child, {
232
+ ownsProcessGroup: this.driver.ownsProcessGroup
233
+ });
234
+ }
219
235
  if (this.inputDirectory) {
220
236
  await rm(this.inputDirectory, { recursive: true, force: true }).catch(() => undefined);
221
237
  this.inputDirectory = undefined;
222
238
  }
223
- const child = this.process;
224
- this.process = undefined;
225
- if (child && child.exitCode === null && child.signalCode === null) {
226
- child.kill();
227
- }
228
239
  }
229
240
  requestPermission(params) {
230
241
  if (this.driver.permissionPolicy === 'deny') {
@@ -440,7 +451,10 @@ export class AcpProcessRuntime {
440
451
  return value;
441
452
  }
442
453
  async promptBlocks(message, attachments) {
443
- const blocks = [{ type: 'text', text: message }];
454
+ const blocks = [
455
+ { type: 'text', text: message },
456
+ { type: 'text', text: TURN_COMPLETION_CONTRACT }
457
+ ];
444
458
  for (const attachment of attachments) {
445
459
  const mediaType = attachment.mediaType || 'application/octet-stream';
446
460
  const data = attachment.bytes.toString('base64');
@@ -0,0 +1 @@
1
+ export declare const TURN_COMPLETION_CONTRACT: string;
@@ -0,0 +1,9 @@
1
+ export const TURN_COMPLETION_CONTRACT = [
2
+ '<agent_nexus_completion_contract>',
3
+ 'Do not end this turn while requested work is still running or incomplete.',
4
+ 'If you need user input or permission, use the protocol input/permission request and wait for the response.',
5
+ 'Before ending the turn, verify the requested work and provide a non-empty final response.',
6
+ 'The final response must summarize the outcome, verification performed, and workspace-relative paths for generated files.',
7
+ 'Attach generated resources or artifacts through the protocol when supported.',
8
+ '</agent_nexus_completion_contract>'
9
+ ].join('\n');
@@ -9,5 +9,6 @@ export declare function createPiDriver(id: string, config: AgentdDriverConfig):
9
9
  env: NodeJS.ProcessEnv;
10
10
  permissionPolicy: import("../types.js").PermissionPolicy;
11
11
  permissionTimeoutMs: number;
12
+ ownsProcessGroup?: boolean;
12
13
  spawn(workspace: string): import("child_process").ChildProcessWithoutNullStreams;
13
14
  };
@@ -28,6 +28,7 @@ export function createStdioAcpDriver(id, config, defaults) {
28
28
  env,
29
29
  permissionPolicy: config.permissionPolicy || 'ask',
30
30
  permissionTimeoutMs: config.permissionTimeoutMs || 15 * 60 * 1000,
31
+ ownsProcessGroup: process.platform !== 'win32',
31
32
  async probe() {
32
33
  try {
33
34
  const version = await probeCommand(command, defaults.probeArgs || ['--version'], env, name);
@@ -55,6 +56,7 @@ export function createStdioAcpDriver(id, config, defaults) {
55
56
  return spawn(command, args, {
56
57
  cwd: workspace,
57
58
  env,
59
+ detached: process.platform !== 'win32',
58
60
  stdio: ['pipe', 'pipe', 'pipe']
59
61
  });
60
62
  }
@@ -9,6 +9,8 @@ export interface AgentDriver {
9
9
  env: NodeJS.ProcessEnv;
10
10
  permissionPolicy: PermissionPolicy;
11
11
  permissionTimeoutMs: number;
12
+ /** The spawned Agent owns a dedicated process group that can be terminated as a tree. */
13
+ ownsProcessGroup?: boolean;
12
14
  probe(): Promise<AgentdAgentView>;
13
15
  spawn(workspace: string): ChildProcessWithoutNullStreams;
14
16
  }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare function startAgentd(configPath: string): Promise<{
10
10
  controlPlane: AgentdControlPlane;
11
11
  close(): Promise<void>;
12
12
  }>;
13
+ export declare function closeServer(server: Server, graceMs?: number): Promise<void>;
13
14
  export * from './config.js';
14
15
  export * from './control-plane.js';
15
16
  export * from './server.js';
package/dist/index.js CHANGED
@@ -24,8 +24,7 @@ export async function startAgentd(configPath) {
24
24
  runStore,
25
25
  controlPlane,
26
26
  async close() {
27
- await closeServer(server);
28
- await sessions.shutdown();
27
+ await Promise.all([closeServer(server), sessions.shutdown()]);
29
28
  await runStore.flush();
30
29
  }
31
30
  };
@@ -45,9 +44,24 @@ function listen(server, port, host) {
45
44
  server.listen(port, host);
46
45
  });
47
46
  }
48
- function closeServer(server) {
47
+ export function closeServer(server, graceMs = 2_000) {
49
48
  return new Promise((resolve, reject) => {
50
- server.close((error) => (error ? reject(error) : resolve()));
49
+ let settled = false;
50
+ const finish = (error) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ clearTimeout(timer);
55
+ if (error)
56
+ reject(error);
57
+ else
58
+ resolve();
59
+ };
60
+ const timer = setTimeout(() => {
61
+ server.closeAllConnections?.();
62
+ finish();
63
+ }, Math.max(0, graceMs));
64
+ server.close((error) => finish(error || undefined));
51
65
  server.closeIdleConnections?.();
52
66
  });
53
67
  }
@@ -0,0 +1,5 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare function terminateProcessTree(child: ChildProcess, options?: {
3
+ ownsProcessGroup?: boolean;
4
+ graceMs?: number;
5
+ }): Promise<void>;
@@ -0,0 +1,105 @@
1
+ import { spawn } from 'node:child_process';
2
+ const DEFAULT_GRACE_MS = 2_000;
3
+ const FORCE_KILL_WAIT_MS = 1_000;
4
+ export async function terminateProcessTree(child, options = {}) {
5
+ const pid = child.pid;
6
+ if (!pid || hasExited(child))
7
+ return;
8
+ const graceMs = Math.max(0, options.graceMs ?? DEFAULT_GRACE_MS);
9
+ if (process.platform === 'win32') {
10
+ await taskkill(pid, false);
11
+ if (await waitForExit(child, graceMs))
12
+ return;
13
+ await taskkill(pid, true);
14
+ await waitForExit(child, FORCE_KILL_WAIT_MS);
15
+ return;
16
+ }
17
+ if (options.ownsProcessGroup === true) {
18
+ signalUnixGroup(pid, 'SIGTERM');
19
+ if (await waitForUnixGroupExit(pid, graceMs))
20
+ return;
21
+ signalUnixGroup(pid, 'SIGKILL');
22
+ await waitForUnixGroupExit(pid, FORCE_KILL_WAIT_MS);
23
+ return;
24
+ }
25
+ signalUnix(child, 'SIGTERM');
26
+ if (await waitForExit(child, graceMs))
27
+ return;
28
+ signalUnix(child, 'SIGKILL');
29
+ await waitForExit(child, FORCE_KILL_WAIT_MS);
30
+ }
31
+ function signalUnix(child, signal) {
32
+ try {
33
+ child.kill(signal);
34
+ }
35
+ catch { }
36
+ }
37
+ function signalUnixGroup(pid, signal) {
38
+ try {
39
+ process.kill(-pid, signal);
40
+ }
41
+ catch { }
42
+ }
43
+ async function waitForUnixGroupExit(pid, timeoutMs) {
44
+ const deadline = Date.now() + timeoutMs;
45
+ while (unixGroupExists(pid)) {
46
+ const remaining = deadline - Date.now();
47
+ if (remaining <= 0)
48
+ return false;
49
+ await delay(Math.min(25, remaining));
50
+ }
51
+ return true;
52
+ }
53
+ function unixGroupExists(pid) {
54
+ try {
55
+ process.kill(-pid, 0);
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ return error.code !== 'ESRCH';
60
+ }
61
+ }
62
+ function waitForExit(child, timeoutMs) {
63
+ if (hasExited(child))
64
+ return Promise.resolve(true);
65
+ return new Promise((resolve) => {
66
+ let settled = false;
67
+ const finish = (exited) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ child.off('exit', onExit);
73
+ resolve(exited);
74
+ };
75
+ const onExit = () => finish(true);
76
+ const timer = setTimeout(() => finish(hasExited(child)), timeoutMs);
77
+ child.once('exit', onExit);
78
+ if (hasExited(child))
79
+ finish(true);
80
+ });
81
+ }
82
+ function taskkill(pid, force) {
83
+ return new Promise((resolve) => {
84
+ const args = ['/PID', String(pid), '/T'];
85
+ if (force)
86
+ args.push('/F');
87
+ const killer = spawn('taskkill.exe', args, { stdio: 'ignore', windowsHide: true });
88
+ const timer = setTimeout(() => {
89
+ killer.kill();
90
+ resolve();
91
+ }, FORCE_KILL_WAIT_MS);
92
+ const finish = () => {
93
+ clearTimeout(timer);
94
+ resolve();
95
+ };
96
+ killer.once('error', finish);
97
+ killer.once('exit', finish);
98
+ });
99
+ }
100
+ function delay(ms) {
101
+ return new Promise((resolve) => setTimeout(resolve, ms));
102
+ }
103
+ function hasExited(child) {
104
+ return child.exitCode !== null || child.signalCode !== null;
105
+ }
@@ -32,7 +32,7 @@ export declare class RunStore {
32
32
  constructor(filePath: string, maxRuns?: number, maxTaskChars?: number);
33
33
  init(): Promise<void>;
34
34
  create(input: CreateRunInput): AgentdRunDetail;
35
- update(id: string, patch: Partial<Pick<StoredRun, 'protocolSessionId' | 'state' | 'progress' | 'output' | 'error' | 'artifacts' | 'artifactCount' | 'updatedAt' | 'endedAt' | 'durationMs' | 'inputAttachmentCount'>>): AgentdRunDetail | undefined;
35
+ update(id: string, patch: Partial<Pick<StoredRun, 'protocolSessionId' | 'state' | 'progress' | 'output' | 'error' | 'artifacts' | 'artifactCount' | 'updatedAt' | 'endedAt' | 'durationMs' | 'inputAttachmentCount' | 'completion'>>): AgentdRunDetail | undefined;
36
36
  get(id: string): AgentdRunDetail | undefined;
37
37
  list(query?: RunListQuery): {
38
38
  runs: AgentdRunView[];
@@ -1,10 +1,11 @@
1
- import type { AgentdArtifact, AgentdEventType, AgentdInputAttachment, AgentdPendingRequest, AgentdPendingResponse, AgentdSessionState } from './types.js';
1
+ import type { AgentdArtifact, AgentdEventType, AgentdInputAttachment, AgentdPendingRequest, AgentdPendingResponse, AgentdSessionState, AgentdTurnCompletionProof } from './types.js';
2
2
  export interface AgentSessionSink {
3
3
  readonly id: string;
4
4
  readonly state: AgentdSessionState;
5
5
  readonly protocolSessionId?: string;
6
6
  setProtocolSessionId(id: string): void;
7
7
  setState(state: AgentdSessionState, error?: string): void;
8
+ completeTurn(proof: AgentdTurnCompletionProof): boolean;
8
9
  appendOutput(text: string): void;
9
10
  addArtifact(artifact: AgentdArtifact): void;
10
11
  setPending(request: AgentdPendingRequest): void;
package/dist/session.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentDriver } from './drivers/index.js';
2
2
  import { SessionEventLog } from './events.js';
3
3
  import { RunStore, type RunListQuery } from './run-store.js';
4
4
  import type { AcpSessionSink, AgentSessionRuntime, AgentSessionSink } from './session-contract.js';
5
- import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView } from './types.js';
5
+ import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView, AgentdTurnCompletionProof } from './types.js';
6
6
  import type { WorkspacePolicy } from './workspace.js';
7
7
  export declare class ManagedSession implements AgentSessionSink, AcpSessionSink {
8
8
  readonly agentId: string;
@@ -23,6 +23,7 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
23
23
  output: string;
24
24
  error?: string;
25
25
  pendingRequest?: AgentdPendingRequest;
26
+ private completion?;
26
27
  private artifacts;
27
28
  private inputAttachments;
28
29
  private runtime?;
@@ -31,6 +32,7 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
31
32
  setProtocolSessionId(id: string): void;
32
33
  setAcpSessionId(id: string): void;
33
34
  setState(state: AgentdSessionState, error?: string): void;
35
+ completeTurn(proof: AgentdTurnCompletionProof): boolean;
34
36
  appendOutput(text: string): void;
35
37
  addArtifact(artifact: AgentdArtifact): void;
36
38
  publishFile(filePath: string): Promise<AgentdArtifact>;
package/dist/session.js CHANGED
@@ -31,6 +31,7 @@ export class ManagedSession {
31
31
  output = '';
32
32
  error;
33
33
  pendingRequest;
34
+ completion;
34
35
  artifacts = [];
35
36
  inputAttachments = new Map();
36
37
  runtime;
@@ -61,6 +62,8 @@ export class ManagedSession {
61
62
  setState(state, error) {
62
63
  if (this.state === state && this.error === error)
63
64
  return;
65
+ if (state !== 'completed')
66
+ this.completion = undefined;
64
67
  this.state = state;
65
68
  this.error = error;
66
69
  this.updatedAt = Date.now();
@@ -71,15 +74,52 @@ export class ManagedSession {
71
74
  : state === 'canceled'
72
75
  ? 'canceled'
73
76
  : 'session_state';
74
- this.events.append(type, { state, ...(error ? { error } : {}) });
77
+ this.events.append(type, {
78
+ state,
79
+ ...(error ? { error } : {}),
80
+ ...(state === 'completed' && this.completion
81
+ ? { completion: structuredClone(this.completion) }
82
+ : {})
83
+ });
75
84
  const endedAt = isTerminal(state) ? this.updatedAt : undefined;
76
85
  this.syncRun({
77
86
  state,
78
87
  error,
79
88
  progress: progressForState(state, error),
89
+ completion: state === 'completed' && this.completion
90
+ ? structuredClone(this.completion)
91
+ : undefined,
80
92
  ...(endedAt ? { endedAt } : {})
81
93
  });
82
94
  }
95
+ completeTurn(proof) {
96
+ if (this.pendingRequest || isInteractive(this.state)) {
97
+ this.emit('terminal_output', {
98
+ stream: 'system',
99
+ text: `Ignored ${proof.source} completion while the session is waiting for ${this.pendingRequest?.kind || 'input'}.`
100
+ });
101
+ return false;
102
+ }
103
+ if (this.state !== 'running')
104
+ return false;
105
+ const outputPresent = this.output.trim().length > 0;
106
+ const artifactCount = this.artifacts.length;
107
+ if (!outputPresent && artifactCount < 1) {
108
+ this.setState('failed', `Agent ended the turn (${proof.stopReason}) without a final response or artifact.`);
109
+ return false;
110
+ }
111
+ this.completion = {
112
+ ...structuredClone(proof),
113
+ runId: this.currentRunId,
114
+ protocol: this.protocol,
115
+ verified: true,
116
+ outputPresent,
117
+ artifactCount,
118
+ completedAt: Date.now()
119
+ };
120
+ this.setState('completed');
121
+ return true;
122
+ }
83
123
  appendOutput(text) {
84
124
  if (!text)
85
125
  return;
@@ -186,6 +226,7 @@ export class ManagedSession {
186
226
  }
187
227
  }
188
228
  setPending(request) {
229
+ this.completion = undefined;
189
230
  this.pendingRequest = structuredClone(request);
190
231
  this.state = request.kind === 'permission' ? 'permission_required' : 'input_required';
191
232
  this.updatedAt = Date.now();
@@ -247,6 +288,7 @@ export class ManagedSession {
247
288
  }
248
289
  this.output = '';
249
290
  this.artifacts = [];
291
+ this.completion = undefined;
250
292
  this.error = undefined;
251
293
  this.updatedAt = Date.now();
252
294
  this.currentRunId = this.runStore?.create({
@@ -307,6 +349,9 @@ export class ManagedSession {
307
349
  pendingRequest: this.pendingRequest
308
350
  ? structuredClone(this.pendingRequest)
309
351
  : undefined,
352
+ completion: this.completion
353
+ ? structuredClone(this.completion)
354
+ : undefined,
310
355
  lastEventId: this.events.lastId,
311
356
  createdAt: this.createdAt,
312
357
  updatedAt: this.updatedAt
@@ -326,6 +371,9 @@ export class ManagedSession {
326
371
  output: this.output || undefined,
327
372
  artifacts: this.artifacts.map(runArtifact),
328
373
  artifactCount: this.artifacts.length,
374
+ completion: this.completion
375
+ ? structuredClone(this.completion)
376
+ : undefined,
329
377
  updatedAt: this.updatedAt,
330
378
  ...patch
331
379
  });
@@ -728,6 +776,9 @@ function isTerminal(state) {
728
776
  function isActive(state) {
729
777
  return state === 'running' || state === 'input_required' || state === 'permission_required';
730
778
  }
779
+ function isInteractive(state) {
780
+ return state === 'input_required' || state === 'permission_required';
781
+ }
731
782
  function errorMessage(error) {
732
783
  return error instanceof Error ? error.message : String(error);
733
784
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  export type AgentdSessionState = 'created' | 'running' | 'input_required' | 'permission_required' | 'completed' | 'failed' | 'canceled';
2
2
  export type AgentdProtocol = 'acp' | 'a2a';
3
3
  export type PermissionPolicy = 'ask' | 'deny';
4
+ export type AgentdTurnCompletionSource = 'acp_prompt_response' | 'a2a_task_status' | 'a2a_message_stream';
5
+ export interface AgentdTurnCompletionProof {
6
+ source: AgentdTurnCompletionSource;
7
+ stopReason: string;
8
+ }
9
+ export interface AgentdTurnCompletion extends AgentdTurnCompletionProof {
10
+ runId?: string;
11
+ protocol: AgentdProtocol;
12
+ verified: true;
13
+ outputPresent: boolean;
14
+ artifactCount: number;
15
+ completedAt: number;
16
+ }
4
17
  export declare const agentdDriverKinds: readonly ["opencode", "claude", "codex", "pi", "openclaw", "hermes"];
5
18
  export type AgentdDriverKind = (typeof agentdDriverKinds)[number];
6
19
  export interface AgentdDriverConfig {
@@ -189,6 +202,8 @@ export interface AgentdSessionView {
189
202
  artifacts: AgentdArtifact[];
190
203
  inputAttachments?: AgentdInputAttachmentView[];
191
204
  pendingRequest?: AgentdPendingRequest;
205
+ /** Structural proof that the current protocol turn reached a valid terminal boundary. */
206
+ completion?: AgentdTurnCompletion;
192
207
  lastEventId?: string;
193
208
  createdAt: number;
194
209
  updatedAt: number;
@@ -223,6 +238,7 @@ export interface AgentdRunView {
223
238
  error?: string;
224
239
  artifactCount: number;
225
240
  inputAttachmentCount?: number;
241
+ completion?: AgentdTurnCompletion;
226
242
  startedAt: number;
227
243
  updatedAt: number;
228
244
  endedAt?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",