@factory/droid-sdk 0.7.0 → 0.8.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 (53) hide show
  1. package/dist/{chunk-C3KHERVH.js → chunk-4G2RKM2H.js} +4976 -3628
  2. package/dist/chunk-4G2RKM2H.js.map +1 -0
  3. package/dist/{chunk-5UXINOXG.mjs → chunk-ROFAQUG4.mjs} +7574 -6226
  4. package/dist/chunk-ROFAQUG4.mjs.map +1 -0
  5. package/dist/{index-D_SzTnFR.d.mts → index-_nUPGHpH.d.mts} +16625 -5933
  6. package/dist/{index-D_SzTnFR.d.ts → index-_nUPGHpH.d.ts} +16625 -5933
  7. package/dist/index.d.mts +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +10 -2
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +9 -1
  12. package/dist/node.d.mts +24 -3
  13. package/dist/node.d.ts +24 -3
  14. package/dist/node.js +504 -374
  15. package/dist/node.js.map +1 -1
  16. package/dist/node.mjs +153 -23
  17. package/dist/node.mjs.map +1 -1
  18. package/docs/typescript-sdk-reference.md +903 -726
  19. package/examples/browser/daemon-model-discovery.ts +47 -0
  20. package/examples/browser/index.html +2 -1
  21. package/examples/browser/list-sessions.ts +1 -3
  22. package/examples/node/abort-session-stream.ts +2 -0
  23. package/examples/node/ask-user-elicitation.ts +11 -7
  24. package/examples/node/compact-session.ts +33 -18
  25. package/examples/node/custom-system-prompt.ts +35 -0
  26. package/examples/node/document-input.ts +58 -0
  27. package/examples/node/enter-spec-mode.ts +50 -29
  28. package/examples/node/external-mcp.ts +45 -0
  29. package/examples/node/fork-session.ts +21 -10
  30. package/examples/node/hook-execution.ts +37 -37
  31. package/examples/node/image-attachment.ts +8 -2
  32. package/examples/node/interrupt-session.ts +27 -8
  33. package/examples/node/model-discovery.ts +28 -0
  34. package/examples/node/model-selection.ts +35 -0
  35. package/examples/node/multi-turn-session.ts +4 -0
  36. package/examples/node/observability.ts +4 -17
  37. package/examples/node/partial-stream.ts +56 -0
  38. package/examples/node/permission-handler.ts +9 -4
  39. package/examples/node/rename-session.ts +7 -3
  40. package/examples/node/result-metadata.ts +10 -1
  41. package/examples/node/resume-session.ts +41 -0
  42. package/examples/node/rewind-session.ts +19 -9
  43. package/examples/node/run.ts +14 -7
  44. package/examples/node/sdk-mcp-tool.ts +10 -8
  45. package/examples/node/session-settings.ts +9 -9
  46. package/examples/node/session-stream.ts +17 -2
  47. package/examples/node/skills.ts +24 -0
  48. package/examples/node/spec-mode-new-session.ts +38 -10
  49. package/examples/node/spec-mode-same-session.ts +37 -3
  50. package/examples/node/structured-output.ts +30 -7
  51. package/package.json +1 -1
  52. package/dist/chunk-5UXINOXG.mjs.map +0 -1
  53. package/dist/chunk-C3KHERVH.js.map +0 -1
@@ -9,28 +9,47 @@
9
9
  * npx tsx examples/node/interrupt-session.ts
10
10
  */
11
11
 
12
- import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
12
+ import {
13
+ type DroidResultMessage,
14
+ createSession,
15
+ DroidMessageType,
16
+ } from '@factory/droid-sdk/node';
13
17
 
14
18
  const session = await createSession();
15
19
  let deltaCount = 0;
20
+ let result: DroidResultMessage | undefined;
16
21
 
17
22
  try {
18
23
  for await (const msg of session.stream('Write a long history of computing.', {
19
24
  includePartialMessages: true,
20
25
  })) {
21
- if (msg.type !== DroidMessageType.AssistantTextDelta) {
22
- continue;
26
+ if (msg.type === DroidMessageType.Result) {
27
+ result = msg;
28
+ if (!msg.success && !msg.interrupted) {
29
+ throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
30
+ }
23
31
  }
24
32
 
25
- process.stdout.write(msg.text);
26
- deltaCount++;
33
+ if (msg.type === DroidMessageType.AssistantTextDelta) {
34
+ process.stdout.write(msg.text);
35
+ deltaCount++;
27
36
 
28
- if (deltaCount === 5) {
29
- await session.interrupt();
37
+ if (deltaCount === 5) {
38
+ await session.interrupt();
39
+ }
30
40
  }
31
41
  }
32
42
 
33
- console.log('\nInterrupted turn.');
43
+ if (!result) {
44
+ throw new Error('The stream ended without a result.');
45
+ }
46
+ if (result.interrupted) {
47
+ console.log('\nTurn was interrupted.');
48
+ } else if (deltaCount >= 5) {
49
+ console.log('\nThe turn completed before the interrupt took effect.');
50
+ } else {
51
+ console.log('\nThe turn completed before an interrupt was requested.');
52
+ }
34
53
  } finally {
35
54
  await session.close();
36
55
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Node model discovery example.
3
+ *
4
+ * Lists the models available to the current account and organization.
5
+ *
6
+ * Usage:
7
+ * FACTORY_API_KEY=... npx tsx examples/node/model-discovery.ts
8
+ */
9
+
10
+ import { listModels } from '@factory/droid-sdk/node';
11
+
12
+ const apiKey = process.env.FACTORY_API_KEY;
13
+ if (!apiKey) {
14
+ throw new Error('Set FACTORY_API_KEY.');
15
+ }
16
+
17
+ const models = await listModels({ apiKey, includeDisabled: true });
18
+
19
+ for (const model of models) {
20
+ const status = model.disabled
21
+ ? `disabled: ${model.disabledReason}`
22
+ : 'available';
23
+ console.log(
24
+ `${model.displayName} (${model.id})\n` +
25
+ ` status: ${status}\n` +
26
+ ` reasoning: ${model.supportedReasoningEfforts.join(', ')}`
27
+ );
28
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Model selection example.
3
+ *
4
+ * Runs one turn with a configured model ID.
5
+ *
6
+ * Usage:
7
+ * FACTORY_API_KEY=... npx tsx examples/node/model-selection.ts model-id
8
+ */
9
+
10
+ import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
11
+
12
+ const modelId = process.argv[2];
13
+ if (!modelId) {
14
+ throw new Error('Pass a configured model ID.');
15
+ }
16
+
17
+ const session = await createSession({
18
+ cwd: process.cwd(),
19
+ modelId,
20
+ });
21
+
22
+ try {
23
+ for await (const message of session.stream('Summarize this repository.')) {
24
+ if (message.type === DroidMessageType.Assistant) {
25
+ console.log(message.text);
26
+ }
27
+ if (message.type === DroidMessageType.Result && !message.success) {
28
+ throw new Error(
29
+ message.error?.message ?? `Turn ended: ${message.subtype}`
30
+ );
31
+ }
32
+ }
33
+ } finally {
34
+ await session.close();
35
+ }
@@ -19,7 +19,11 @@ async function streamText(
19
19
  if (msg.type === DroidMessageType.Assistant) {
20
20
  text += msg.text;
21
21
  }
22
+ if (msg.type === DroidMessageType.Result && !msg.success) {
23
+ throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
24
+ }
22
25
  }
26
+ if (!text) throw new Error('The turn produced no assistant text.');
23
27
  return text;
24
28
  }
25
29
 
@@ -2,14 +2,7 @@
2
2
  * Observability example.
3
3
  *
4
4
  * Passes a `DroidObservability` bundle (logger, metrics, tracing) to
5
- * `createSession()`, which wires it into both the spawned droid process
6
- * transport and the client. Spawning the process records a spawn-latency
7
- * metric and a log; every outgoing request asks the tracing sink to inject
8
- * trace context into the request's `_meta`.
9
- *
10
- * The example then runs one real conversational turn through
11
- * `session.stream()` so the sinks observe request/response traffic from actual
12
- * work rather than only process startup.
5
+ * `createSession()`, then runs one turn so the sinks observe real traffic.
13
6
  *
14
7
  * Sinks only ever receive structured, privacy-safe values — never raw
15
8
  * stdout/stderr or message content.
@@ -60,10 +53,6 @@ const observability: DroidObservability = {
60
53
 
61
54
  const session = await createSession({ observability });
62
55
 
63
- // Snapshot before the turn. Spawning the process alone emits a log, so a
64
- // total-count assertion would pass having observed only startup. Logs are also
65
- // the only turn-scoped signal here: the SDK's sole metric is spawn latency,
66
- // recorded before the turn begins, so metrics cannot witness turn traffic.
67
56
  const logCountBeforeTurn = logCount;
68
57
  let turnLogCount = 0;
69
58
 
@@ -71,13 +60,11 @@ try {
71
60
  for await (const msg of session.stream(
72
61
  'Reply with the single word: ready.'
73
62
  )) {
74
- if (msg.type === DroidMessageType.Assistant) {
75
- console.log(`[assistant] ${msg.text}`);
63
+ if (msg.type === DroidMessageType.Result && !msg.success) {
64
+ throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
76
65
  }
77
66
  }
78
- // Measure before teardown: `session.close()` sends a `close_session` request,
79
- // which is itself logged, so counting after the `finally` would always find
80
- // at least one log regardless of what the turn did.
67
+ // Measure before close(), which emits another request log.
81
68
  turnLogCount = logCount - logCountBeforeTurn;
82
69
  } finally {
83
70
  await session.close();
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Partial stream example.
3
+ *
4
+ * Prints assistant text as it arrives, tool progress, and cumulative session
5
+ * token updates.
6
+ *
7
+ * Usage:
8
+ * npx tsx examples/node/partial-stream.ts
9
+ * npx tsx examples/node/partial-stream.ts "Explain this repository."
10
+ */
11
+
12
+ import { DroidMessageType, createSession } from '@factory/droid-sdk/node';
13
+
14
+ const session = await createSession();
15
+
16
+ try {
17
+ const prompt =
18
+ process.argv.slice(2).join(' ') || 'Explain this repository briefly.';
19
+ let sawSuccessfulResult = false;
20
+
21
+ for await (const event of session.stream(prompt, {
22
+ includePartialMessages: true,
23
+ })) {
24
+ switch (event.type) {
25
+ case DroidMessageType.AssistantTextDelta:
26
+ process.stdout.write(event.text);
27
+ break;
28
+
29
+ case DroidMessageType.ToolProgress:
30
+ console.log(`\n[tool progress] ${event.toolUseId}`);
31
+ break;
32
+
33
+ case DroidMessageType.TokenUsageUpdate:
34
+ console.log(
35
+ `\n[session tokens] ${event.inputTokens} in, ${event.outputTokens} out`
36
+ );
37
+ break;
38
+
39
+ case DroidMessageType.Result:
40
+ if (!event.success) {
41
+ throw new Error(
42
+ event.error?.message ?? `Turn ended: ${event.subtype}`
43
+ );
44
+ }
45
+ sawSuccessfulResult = true;
46
+ console.log(`\n[result] ${event.subtype}`);
47
+ break;
48
+ }
49
+ }
50
+
51
+ if (!sawSuccessfulResult) {
52
+ throw new Error('The stream ended without a successful result.');
53
+ }
54
+ } finally {
55
+ await session.close();
56
+ }
@@ -25,19 +25,24 @@ try {
25
25
  `Create ${outputPath} with the text "Hello, World!"`,
26
26
  {
27
27
  autonomyLevel: AutonomyLevel.Off,
28
- permissionHandler(params) {
28
+ permissionHandler({ toolUses, options }) {
29
29
  handledPermission = true;
30
30
  const onlyCreatesExpectedFile =
31
- params.toolUses.length > 0 &&
32
- params.toolUses.every(
31
+ toolUses.length > 0 &&
32
+ toolUses.every(
33
33
  (item) =>
34
34
  item.details.type === ToolConfirmationType.Create &&
35
35
  item.details.filePath === outputPath
36
36
  );
37
37
 
38
- return onlyCreatesExpectedFile
38
+ const desired = onlyCreatesExpectedFile
39
39
  ? ToolConfirmationOutcome.ProceedOnce
40
40
  : ToolConfirmationOutcome.Cancel;
41
+
42
+ const offered = options.find((option) => option.value === desired);
43
+ if (!offered) throw new Error(`Outcome not offered: ${desired}`);
44
+
45
+ return desired;
41
46
  },
42
47
  }
43
48
  );
@@ -9,14 +9,18 @@
9
9
  * npx tsx examples/node/rename-session.ts
10
10
  */
11
11
 
12
- import { createSession } from '@factory/droid-sdk/node';
12
+ import { DroidMessageType, createSession } from '@factory/droid-sdk/node';
13
13
 
14
14
  const session = await createSession();
15
15
 
16
16
  try {
17
17
  // A session needs at least one turn before it has a title to replace.
18
- for await (const _msg of session.stream('Reply with exactly: OK')) {
19
- // Consume the stream so the turn completes.
18
+ for await (const message of session.stream('Reply with exactly: OK')) {
19
+ if (message.type === DroidMessageType.Result && !message.success) {
20
+ throw new Error(
21
+ message.error?.message ?? `Turn ended: ${message.subtype}`
22
+ );
23
+ }
20
24
  }
21
25
 
22
26
  await session.rename({
@@ -29,6 +29,15 @@ if (result.error) {
29
29
 
30
30
  if (result.tokenUsage) {
31
31
  console.log(
32
- `tokens: ${result.tokenUsage.inputTokens} in, ${result.tokenUsage.outputTokens} out`
32
+ `turn tokens: ${result.tokenUsage.inputTokens} in, ${result.tokenUsage.outputTokens} out`
33
+ );
34
+ console.log(
35
+ `turn cache: ${result.tokenUsage.cacheReadTokens} read, ${result.tokenUsage.cacheCreationTokens} created`
36
+ );
37
+ }
38
+
39
+ if (!result.success) {
40
+ throw new Error(
41
+ result.error?.message ?? `The turn failed (${result.subtype}).`
33
42
  );
34
43
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Resume session example.
3
+ *
4
+ * Loads a saved session and sends a follow-up prompt with its existing
5
+ * conversation, working directory, and settings.
6
+ *
7
+ * Usage:
8
+ * npx tsx examples/node/resume-session.ts session-id
9
+ */
10
+
11
+ import { DroidMessageType, resumeSession } from '@factory/droid-sdk/node';
12
+
13
+ const sessionId = process.argv[2];
14
+ if (!sessionId) {
15
+ throw new Error('Pass a session ID. Use listSessions() to find one.');
16
+ }
17
+
18
+ const session = await resumeSession(sessionId);
19
+
20
+ try {
21
+ console.log(`cwd: ${String(session.cwd)}`);
22
+ console.log(`model: ${session.settings.modelId}`);
23
+
24
+ let assistantText = '';
25
+ for await (const message of session.stream(
26
+ 'Continue from the last conversation.'
27
+ )) {
28
+ if (message.type === DroidMessageType.Assistant) {
29
+ assistantText += message.text;
30
+ console.log(message.text);
31
+ }
32
+ if (message.type === DroidMessageType.Result && !message.success) {
33
+ throw new Error(
34
+ message.error?.message ?? `Turn ended: ${message.subtype}`
35
+ );
36
+ }
37
+ }
38
+ if (!assistantText) throw new Error('The turn produced no assistant text.');
39
+ } finally {
40
+ await session.close();
41
+ }
@@ -15,8 +15,9 @@ const session = await createSession();
15
15
  async function ask(
16
16
  activeSession: Awaited<ReturnType<typeof createSession>>,
17
17
  prompt: string
18
- ): Promise<string> {
18
+ ): Promise<{ messageId: string; text: string }> {
19
19
  let messageId: string | undefined;
20
+ let text = '';
20
21
 
21
22
  for await (const message of activeSession.stream(prompt)) {
22
23
  if (
@@ -26,17 +27,24 @@ async function ask(
26
27
  messageId ??= message.message.id;
27
28
  }
28
29
  if (message.type === DroidMessageType.Assistant) {
30
+ text += message.text;
29
31
  console.log(message.text);
30
32
  }
33
+ if (message.type === DroidMessageType.Result && !message.success) {
34
+ throw new Error(
35
+ message.error?.message ?? `Turn ended: ${message.subtype}`
36
+ );
37
+ }
31
38
  }
32
39
 
33
40
  if (!messageId) throw new Error('The turn produced no user message ID.');
34
- return messageId;
41
+ if (!text.trim()) throw new Error('The turn produced no assistant output.');
42
+ return { messageId, text };
35
43
  }
36
44
 
37
45
  try {
38
46
  await ask(session, 'Remember this phrase exactly: mango sunrise');
39
- const secondTurn = await ask(
47
+ const { messageId: secondTurn } = await ask(
40
48
  session,
41
49
  'Now forget it and remember: cobalt harbor'
42
50
  );
@@ -56,13 +64,15 @@ try {
56
64
  forkTitle: 'Before the second phrase',
57
65
  });
58
66
 
59
- // `session` is now a retired wrapper. Its persisted ID can still be passed
60
- // to resumeSession() later if the pre-rewind history is needed again.
61
- console.log(`rewound into session: ${successor.id}`);
62
-
63
- await ask(successor, 'What phrase did I ask you to remember?');
67
+ try {
68
+ // `session` is now a retired wrapper. Its persisted ID can still be passed
69
+ // to resumeSession() later if the pre-rewind history is needed again.
70
+ console.log(`rewound into session: ${successor.id}`);
64
71
 
65
- await successor.close();
72
+ await ask(successor, 'What phrase did I ask you to remember?');
73
+ } finally {
74
+ await successor.close();
75
+ }
66
76
  } finally {
67
77
  // Closes the active source, or does nothing if it was retired.
68
78
  await session.close();
@@ -11,15 +11,22 @@
11
11
 
12
12
  import { run } from '@factory/droid-sdk/node';
13
13
 
14
- const { text, messages, tokenUsage } = await run(
15
- process.argv.slice(2).join(' ') || 'What is 2 + 2?'
16
- );
14
+ const result = await run(process.argv.slice(2).join(' ') || 'What is 2 + 2?');
17
15
 
18
- console.log(text);
19
- console.log(`\nmessages: ${messages.length}`);
16
+ if (!result.success) {
17
+ throw new Error(
18
+ result.error?.message ?? `The turn failed (${result.subtype}).`
19
+ );
20
+ }
21
+ if (!result.text.trim()) {
22
+ throw new Error('The turn produced no assistant output.');
23
+ }
24
+
25
+ console.log(result.text);
26
+ console.log(`\nmessages: ${result.messages.length}`);
20
27
 
21
- if (tokenUsage) {
28
+ if (result.tokenUsage) {
22
29
  console.log(
23
- `tokens: ${tokenUsage.inputTokens} in, ${tokenUsage.outputTokens} out`
30
+ `tokens: ${result.tokenUsage.inputTokens} in, ${result.tokenUsage.outputTokens} out`
24
31
  );
25
32
  }
@@ -32,21 +32,23 @@ const sdkTools = createSdkMcpServer({
32
32
 
33
33
  const session = await createSession({
34
34
  mcpServers: [sdkTools],
35
- // Not the focus of this example, but required: without a handler every
36
- // permission request resolves to Cancel, so a tool needing approval would
37
- // never run.
38
35
  permissionHandler: () => ToolConfirmationOutcome.ProceedOnce,
39
36
  });
40
37
 
41
38
  try {
42
- for await (const msg of session.stream(
39
+ for await (const message of session.stream(
43
40
  'Use the favorite_number tool for Ada and tell me the answer.'
44
41
  )) {
45
- if (msg.type === DroidMessageType.ToolCall) {
46
- console.log(`[tool call] ${msg.name}`);
42
+ if (message.type === DroidMessageType.ToolCall) {
43
+ console.log(`[tool call] ${message.name}`);
47
44
  }
48
- if (msg.type === DroidMessageType.Assistant) {
49
- console.log(msg.text);
45
+ if (message.type === DroidMessageType.Assistant) {
46
+ console.log(message.text);
47
+ }
48
+ if (message.type === DroidMessageType.Result && !message.success) {
49
+ throw new Error(
50
+ message.error?.message ?? `Turn ended: ${message.subtype}`
51
+ );
50
52
  }
51
53
  }
52
54
  } finally {
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * Session settings example.
3
3
  *
4
- * Reads settings from a live session, updates them with `updateSettings()`,
5
- * and reads the updated values back.
4
+ * Reads settings from a live session, updates them, and changes between Auto
5
+ * and Spec modes.
6
6
  *
7
7
  * Usage:
8
8
  * npx tsx examples/node/session-settings.ts
9
9
  */
10
10
 
11
- import {
12
- AutonomyLevel,
13
- DroidInteractionMode,
14
- createSession,
15
- } from '@factory/droid-sdk/node';
11
+ import { AutonomyLevel, createSession } from '@factory/droid-sdk/node';
16
12
 
17
13
  const session = await createSession({ autonomyLevel: AutonomyLevel.Low });
18
14
 
@@ -25,11 +21,15 @@ try {
25
21
  printSettings('created with');
26
22
 
27
23
  await session.updateSettings({
28
- interactionMode: DroidInteractionMode.Spec,
29
24
  autonomyLevel: AutonomyLevel.High,
30
25
  });
31
-
32
26
  printSettings('updated to ');
27
+
28
+ await session.enterSpecMode();
29
+ printSettings('entered Spec');
30
+
31
+ await session.exitSpecMode();
32
+ printSettings('exited Spec ');
33
33
  } finally {
34
34
  await session.close();
35
35
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Session streaming example.
3
3
  *
4
- * Sends a prompt with `session.stream()` and handles each message type:
5
- * assistant text, tool calls, tool results, and the final result.
4
+ * Sends a prompt with `session.stream()` and handles every message type
5
+ * emitted by a default (non-partial) stream.
6
6
  *
7
7
  * Usage:
8
8
  * npx tsx examples/node/session-stream.ts
@@ -22,6 +22,10 @@ try {
22
22
  process.stdout.write(msg.text);
23
23
  break;
24
24
 
25
+ case DroidMessageType.User:
26
+ console.log('\n[user message accepted]');
27
+ break;
28
+
25
29
  case DroidMessageType.ToolCall:
26
30
  console.log(`\n[tool] ${msg.name}`);
27
31
  break;
@@ -30,7 +34,18 @@ try {
30
34
  console.log(`[tool result] ${msg.isError ? 'error' : 'ok'}`);
31
35
  break;
32
36
 
37
+ case DroidMessageType.Hook:
38
+ console.log(`\n[hook] ${msg.hookId}: ${msg.status}`);
39
+ break;
40
+
41
+ case DroidMessageType.Error:
42
+ console.error(`\n[error] ${msg.message}`);
43
+ break;
44
+
33
45
  case DroidMessageType.Result:
46
+ if (!msg.success) {
47
+ throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
48
+ }
34
49
  if (msg.tokenUsage) {
35
50
  console.log(
36
51
  `\n\ntokens: ${msg.tokenUsage.inputTokens} in, ${msg.tokenUsage.outputTokens} out`
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Skills example.
3
+ *
4
+ * Lists the skills available to a Node session.
5
+ *
6
+ * Usage:
7
+ * npx tsx examples/node/skills.ts
8
+ */
9
+
10
+ import { createSession } from '@factory/droid-sdk/node';
11
+
12
+ const session = await createSession();
13
+
14
+ try {
15
+ const { skills } = await session.listSkills();
16
+
17
+ for (const skill of skills) {
18
+ console.log(
19
+ `${skill.name}: ${skill.enabled === false ? 'disabled' : 'enabled'}`
20
+ );
21
+ }
22
+ } finally {
23
+ await session.close();
24
+ }
@@ -22,7 +22,9 @@ import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
23
 
24
24
  import {
25
+ type ClientPermissionHandler,
25
26
  DroidInteractionMode,
27
+ DroidMessageType,
26
28
  ReasoningEffort,
27
29
  SessionNotificationSchema,
28
30
  ToolConfirmationOutcome,
@@ -35,18 +37,40 @@ const outputPath = join(tempDir, 'hello.txt');
35
37
 
36
38
  let handoffSessionId: string | null = null;
37
39
 
40
+ const handlePermissionRequest: ClientPermissionHandler = ({
41
+ toolUses,
42
+ options,
43
+ }) => {
44
+ const exitsSpecMode =
45
+ toolUses.length > 0 &&
46
+ toolUses.every(
47
+ (toolUse) => toolUse.details.type === ToolConfirmationType.ExitSpecMode
48
+ );
49
+ const createsExpectedFile =
50
+ toolUses.length > 0 &&
51
+ toolUses.every(
52
+ (toolUse) =>
53
+ toolUse.details.type === ToolConfirmationType.Create &&
54
+ toolUse.details.filePath === outputPath
55
+ );
56
+
57
+ let outcome = ToolConfirmationOutcome.Cancel;
58
+ if (exitsSpecMode) {
59
+ outcome = ToolConfirmationOutcome.ProceedNewSessionHigh;
60
+ } else if (createsExpectedFile) {
61
+ outcome = ToolConfirmationOutcome.ProceedOnce;
62
+ }
63
+ if (!options.some((option) => option.value === outcome)) {
64
+ throw new Error(`Outcome not offered: ${outcome}`);
65
+ }
66
+ return outcome;
67
+ };
68
+
38
69
  try {
39
70
  const session = await createSession({
40
71
  interactionMode: DroidInteractionMode.Spec,
41
72
  specModeReasoningEffort: ReasoningEffort.High,
42
- permissionHandler(params) {
43
- const canExitSpec = params.toolUses.some(
44
- (item) => item.details.type === ToolConfirmationType.ExitSpecMode
45
- );
46
- return canExitSpec
47
- ? ToolConfirmationOutcome.ProceedNewSessionHigh
48
- : ToolConfirmationOutcome.ProceedOnce;
49
- },
73
+ permissionHandler: handlePermissionRequest,
50
74
  });
51
75
 
52
76
  // The typed message stream carries no per-message session id, and the
@@ -63,10 +87,14 @@ try {
63
87
  });
64
88
 
65
89
  try {
66
- for await (const _msg of session.stream(
90
+ for await (const message of session.stream(
67
91
  `Plan then create ${outputPath} containing "Hello from Droid".`
68
92
  )) {
69
- // Consume the stream until the handoff implementation finishes.
93
+ if (message.type === DroidMessageType.Result && !message.success) {
94
+ throw new Error(
95
+ message.error?.message ?? `Turn ended: ${message.subtype}`
96
+ );
97
+ }
70
98
  }
71
99
  } finally {
72
100
  unsubscribe();