@factory/droid-sdk 0.7.0 → 0.9.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-FJP5OKS5.js} +6250 -4329
  2. package/dist/chunk-FJP5OKS5.js.map +1 -0
  3. package/dist/{chunk-5UXINOXG.mjs → chunk-KXDG5ZWS.mjs} +8897 -6976
  4. package/dist/chunk-KXDG5ZWS.mjs.map +1 -0
  5. package/dist/{index-D_SzTnFR.d.mts → index-BowvC0_E.d.mts} +43128 -10981
  6. package/dist/{index-D_SzTnFR.d.ts → index-BowvC0_E.d.ts} +43128 -10981
  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 +25 -3
  13. package/dist/node.d.ts +25 -3
  14. package/dist/node.js +565 -423
  15. package/dist/node.js.map +1 -1
  16. package/dist/node.mjs +186 -44
  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
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lists canonical model metadata through a daemon connection.
3
+ *
4
+ * Runs in a real browser (no `process`, no Node builtins). From a source
5
+ * checkout of this repository:
6
+ * npm run build
7
+ * FACTORY_API_KEY=... npm run serve:browser-example
8
+ * open http://127.0.0.1:8420/?example=daemon-model-discovery
9
+ *
10
+ * Never commit a real API key or deploy it in browser code.
11
+ */
12
+
13
+ import { connectToDaemon } from '@factory/droid-sdk';
14
+
15
+ const { daemonUrl, apiKey } = (
16
+ globalThis as typeof globalThis & {
17
+ __DROID_SDK_EXAMPLE_CONFIG__: {
18
+ daemonUrl: string;
19
+ apiKey: string;
20
+ };
21
+ }
22
+ ).__DROID_SDK_EXAMPLE_CONFIG__;
23
+ if (!apiKey) {
24
+ throw new Error('Set FACTORY_API_KEY.');
25
+ }
26
+
27
+ const droid = await connectToDaemon({
28
+ url: daemonUrl,
29
+ auth: { apiKey },
30
+ });
31
+
32
+ try {
33
+ const models = await droid.models.list({ includeDisabled: true });
34
+
35
+ for (const model of models) {
36
+ const status = model.disabled
37
+ ? `disabled: ${model.disabledReason}`
38
+ : 'available';
39
+ console.log(
40
+ `${model.displayName} (${model.id})\n` +
41
+ ` status: ${status}\n` +
42
+ ` reasoning: ${model.supportedReasoningEfforts.join(', ')}`
43
+ );
44
+ }
45
+ } finally {
46
+ droid.disconnect();
47
+ }
@@ -36,7 +36,8 @@
36
36
  <code>DROID_SDK_EXAMPLE_CWD</code> before starting the server, then pick
37
37
  one with <code>?example=daemon-session</code> (default),
38
38
  <code>?example=daemon-multi-session</code>,
39
- <code>?example=daemon-lifecycle</code>, or
39
+ <code>?example=daemon-lifecycle</code>,
40
+ <code>?example=daemon-model-discovery</code>, or
40
41
  <code>?example=list-sessions</code>.
41
42
  </p>
42
43
 
@@ -37,9 +37,7 @@ try {
37
37
  }
38
38
 
39
39
  for (const session of sessions) {
40
- const messages = await droid.sessions.getMessages(session.id, {
41
- limit: 20,
42
- });
40
+ const messages = await session.getMessages({ limit: 20 });
43
41
 
44
42
  console.log(
45
43
  session.id,
@@ -24,7 +24,9 @@ try {
24
24
  )) {
25
25
  // Consume the stream until the abort signal cuts it short.
26
26
  }
27
+ console.log('The turn completed before the abort signal fired.');
27
28
  } catch (error) {
29
+ if (!controller.signal.aborted) throw error;
28
30
  console.log(error instanceof Error ? error.message : String(error));
29
31
  } finally {
30
32
  clearTimeout(timeout);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * AskUser elicitation example.
3
3
  *
4
- * When Droid needs input mid-turn it sends an `askUser` request. `askUserHandler`
5
- * answers it programmatically; without a handler the request goes unanswered.
4
+ * When Droid needs input mid-turn, `askUserHandler` answers it. Without a
5
+ * handler, the request is declined.
6
6
  *
7
7
  * Usage:
8
8
  * npx tsx examples/node/ask-user-elicitation.ts
@@ -10,11 +10,11 @@
10
10
 
11
11
  import { run } from '@factory/droid-sdk/node';
12
12
 
13
- const { text } = await run(
13
+ const result = await run(
14
14
  'Use the AskUser tool to ask me which color I prefer, then reply with the color I picked.',
15
15
  {
16
- askUserHandler(params) {
17
- for (const question of params.questions) {
16
+ askUserHandler({ questions }) {
17
+ for (const question of questions) {
18
18
  console.log(`[asked] ${question.question}`);
19
19
  console.log(`[options] ${question.options.join(', ')}`);
20
20
  }
@@ -22,7 +22,7 @@ const { text } = await run(
22
22
  // Answer every question with its first option. A real integration would
23
23
  // prompt the user; returning `cancelled: true` declines instead.
24
24
  return {
25
- answers: params.questions.map((question) => ({
25
+ answers: questions.map((question) => ({
26
26
  index: question.index,
27
27
  question: question.question,
28
28
  answer: question.options[0] ?? 'no preference',
@@ -32,4 +32,8 @@ const { text } = await run(
32
32
  }
33
33
  );
34
34
 
35
- console.log(`\n${text}`);
35
+ if (!result.success) {
36
+ throw new Error(result.error?.message ?? 'The turn was interrupted.');
37
+ }
38
+
39
+ console.log(`\n${result.text}`);
@@ -10,6 +10,25 @@
10
10
 
11
11
  import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
12
12
 
13
+ async function streamText(
14
+ activeSession: Awaited<ReturnType<typeof createSession>>,
15
+ prompt: string
16
+ ): Promise<string> {
17
+ let text = '';
18
+ for await (const msg of activeSession.stream(prompt)) {
19
+ if (msg.type === DroidMessageType.Assistant) {
20
+ text += msg.text;
21
+ }
22
+ if (msg.type === DroidMessageType.Result && !msg.success) {
23
+ throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
24
+ }
25
+ }
26
+ if (!text.trim()) {
27
+ throw new Error('The turn produced no assistant output.');
28
+ }
29
+ return text;
30
+ }
31
+
13
32
  const session = await createSession();
14
33
 
15
34
  try {
@@ -18,30 +37,26 @@ try {
18
37
  'Say "two" and nothing else',
19
38
  'Say "three" and nothing else',
20
39
  ]) {
21
- for await (const msg of session.stream(prompt)) {
22
- if (msg.type === DroidMessageType.Assistant) {
23
- console.log(msg.text);
24
- }
25
- }
40
+ console.log(await streamText(session, prompt));
26
41
  }
27
42
 
28
43
  const { session: compacted, removedCount } = await session.compact();
29
44
 
30
- // `session` is now a retired wrapper. Its persisted ID can still be passed
31
- // to resumeSession() later if the uncompacted history is needed again.
32
- console.log(`\noriginal session: ${session.id}`);
33
- console.log(`compacted into: ${compacted.id}`);
34
- console.log(`messages removed: ${removedCount}`);
45
+ try {
46
+ // `session` is now a retired wrapper. Its persisted ID can still be passed
47
+ // to resumeSession() later if the uncompacted history is needed again.
48
+ console.log(`\noriginal session: ${session.id}`);
49
+ console.log(`compacted into: ${compacted.id}`);
50
+ console.log(`messages removed: ${removedCount}`);
35
51
 
36
- for await (const msg of compacted.stream(
37
- 'Which numbers did I ask you to say?'
38
- )) {
39
- if (msg.type === DroidMessageType.Assistant) {
40
- console.log(`continued: ${msg.text}`);
41
- }
52
+ const continued = await streamText(
53
+ compacted,
54
+ 'Which numbers did I ask you to say?'
55
+ );
56
+ console.log(`continued: ${continued}`);
57
+ } finally {
58
+ await compacted.close();
42
59
  }
43
-
44
- await compacted.close();
45
60
  } finally {
46
61
  // Closes the active source, or does nothing if it was retired.
47
62
  await session.close();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Custom system prompt example.
3
+ *
4
+ * Usage:
5
+ * npx tsx examples/node/custom-system-prompt.ts
6
+ */
7
+
8
+ import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
9
+
10
+ const session = await createSession({
11
+ systemPrompt: {
12
+ type: 'preset',
13
+ preset: 'droid',
14
+ append: 'Keep answers concise and cite relevant files.',
15
+ },
16
+ });
17
+
18
+ try {
19
+ let text = '';
20
+ for await (const message of session.stream(
21
+ 'Summarize the main entry points in this project.'
22
+ )) {
23
+ if (message.type === DroidMessageType.Assistant) {
24
+ text += message.text;
25
+ }
26
+ if (message.type === DroidMessageType.Result && !message.success) {
27
+ throw new Error(
28
+ message.error?.message ?? `Turn ended: ${message.subtype}`
29
+ );
30
+ }
31
+ }
32
+ console.log(text);
33
+ } finally {
34
+ await session.close();
35
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Document input example.
3
+ *
4
+ * Attaches plain text or a base64 PDF through `files`. With no path, it uses
5
+ * an embedded text document so the example runs as-is.
6
+ *
7
+ * Usage:
8
+ * npx tsx examples/node/document-input.ts
9
+ * npx tsx examples/node/document-input.ts ./report.txt
10
+ * npx tsx examples/node/document-input.ts ./report.pdf
11
+ */
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+ import { basename, extname } from 'node:path';
15
+
16
+ import { run, type DocumentSource } from '@factory/droid-sdk/node';
17
+
18
+ const inputPath = process.argv[2];
19
+ let document: DocumentSource;
20
+
21
+ if (!inputPath) {
22
+ document = {
23
+ type: 'text',
24
+ mediaType: 'text/plain',
25
+ data: [
26
+ 'Release: 2.4.0',
27
+ 'Status: Ready',
28
+ 'Main change: Added document input to the TypeScript SDK guide.',
29
+ ].join('\n'),
30
+ name: 'release-notes.txt',
31
+ };
32
+ } else if (extname(inputPath).toLowerCase() === '.pdf') {
33
+ document = {
34
+ type: 'base64',
35
+ mediaType: 'application/pdf',
36
+ data: (await readFile(inputPath)).toString('base64'),
37
+ name: basename(inputPath),
38
+ };
39
+ } else {
40
+ document = {
41
+ type: 'text',
42
+ mediaType: 'text/plain',
43
+ data: await readFile(inputPath, 'utf8'),
44
+ name: basename(inputPath),
45
+ };
46
+ }
47
+
48
+ const result = await run('Summarize this document in one sentence.', {
49
+ files: [document],
50
+ });
51
+
52
+ if (!result.success) {
53
+ throw new Error(
54
+ result.error?.message ?? `Document summary failed (${result.subtype}).`
55
+ );
56
+ }
57
+
58
+ console.log(result.text);
@@ -1,13 +1,8 @@
1
1
  /**
2
- * Enter spec mode on a live session.
2
+ * Auto to Spec to implementation example.
3
3
  *
4
- * The other spec-mode examples start the session in spec mode. This one starts
5
- * in the default mode and switches mid-session with `enterSpecMode()`, which is
6
- * what a host app does when the user toggles spec mode on an open conversation.
7
- *
8
- * The switch is observable in the confirmations Droid asks for: it proposes a
9
- * plan (`ExitSpecMode`) before touching the filesystem, instead of going
10
- * straight to `Create`.
4
+ * Switches an Auto session to Spec mode, approves the plan, then allows the
5
+ * planned file creation in the same session.
11
6
  *
12
7
  * Usage:
13
8
  * npx tsx examples/node/enter-spec-mode.ts
@@ -18,6 +13,9 @@ import { tmpdir } from 'node:os';
18
13
  import { join } from 'node:path';
19
14
 
20
15
  import {
16
+ type ClientPermissionHandler,
17
+ DroidInteractionMode,
18
+ DroidMessageType,
21
19
  ToolConfirmationOutcome,
22
20
  ToolConfirmationType,
23
21
  createSession,
@@ -26,41 +24,64 @@ import {
26
24
  const tempDir = await mkdtemp(join(tmpdir(), 'droid-sdk-enter-spec-'));
27
25
  const outputPath = join(tempDir, 'hello.txt');
28
26
 
27
+ const handlePermissionRequest: ClientPermissionHandler = ({
28
+ toolUses,
29
+ options,
30
+ }) => {
31
+ const exitsSpecMode =
32
+ toolUses.length > 0 &&
33
+ toolUses.every(
34
+ (toolUse) => toolUse.details.type === ToolConfirmationType.ExitSpecMode
35
+ );
36
+ const createsExpectedFile =
37
+ toolUses.length > 0 &&
38
+ toolUses.every(
39
+ (toolUse) =>
40
+ toolUse.details.type === ToolConfirmationType.Create &&
41
+ toolUse.details.filePath === outputPath
42
+ );
43
+
44
+ if (exitsSpecMode) {
45
+ for (const toolUse of toolUses) {
46
+ if (toolUse.details.type === ToolConfirmationType.ExitSpecMode) {
47
+ console.log(toolUse.details.plan);
48
+ }
49
+ }
50
+ }
51
+
52
+ const outcome =
53
+ exitsSpecMode || createsExpectedFile
54
+ ? ToolConfirmationOutcome.ProceedOnce
55
+ : ToolConfirmationOutcome.Cancel;
56
+ if (!options.some((option) => option.value === outcome)) {
57
+ throw new Error(`Outcome not offered: ${outcome}`);
58
+ }
59
+ return outcome;
60
+ };
61
+
29
62
  try {
30
63
  const session = await createSession({
31
- permissionHandler(params) {
32
- for (const toolUse of params.toolUses) {
33
- const label =
34
- toolUse.details.type === ToolConfirmationType.ExitSpecMode
35
- ? 'plan proposed'
36
- : 'implementing';
37
- console.log(`[${label}] ${toolUse.details.type}`);
38
- }
39
- // Approving ExitSpecMode accepts the plan and lets implementation run.
40
- return ToolConfirmationOutcome.ProceedOnce;
41
- },
64
+ interactionMode: DroidInteractionMode.Auto,
65
+ permissionHandler: handlePermissionRequest,
42
66
  });
43
67
 
44
68
  try {
45
- console.log(
46
- `mode at creation: ${String(session.settings.interactionMode)}`
47
- );
48
-
49
69
  await session.enterSpecMode();
50
- console.log('switched to spec mode\n');
51
70
 
52
- for await (const _msg of session.stream(
71
+ for await (const message of session.stream(
53
72
  `Create ${outputPath} containing "hello".`
54
73
  )) {
55
- // Consume the stream until the plan is approved and implemented.
74
+ if (message.type === DroidMessageType.Result && !message.success) {
75
+ throw new Error(
76
+ message.error?.message ?? `Turn ended: ${message.subtype}`
77
+ );
78
+ }
56
79
  }
57
80
  } finally {
58
81
  await session.close();
59
82
  }
60
83
 
61
- console.log(
62
- `\nfile contents: ${JSON.stringify(await readFile(outputPath, 'utf8'))}`
63
- );
84
+ console.log(await readFile(outputPath, 'utf8'));
64
85
  } finally {
65
86
  await rm(tempDir, { recursive: true, force: true });
66
87
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * External MCP server example.
3
+ *
4
+ * Connects an HTTP MCP server to one Node session, then lists its status and
5
+ * discovered tools.
6
+ *
7
+ * Usage:
8
+ * MCP_SERVER_URL=https://example.com/mcp \
9
+ * npx tsx examples/node/external-mcp.ts
10
+ */
11
+
12
+ import { createSession } from '@factory/droid-sdk/node';
13
+
14
+ const url = process.env.MCP_SERVER_URL;
15
+ if (!url) {
16
+ throw new Error('Set MCP_SERVER_URL to an HTTP MCP endpoint.');
17
+ }
18
+
19
+ const token = process.env.MCP_SERVER_TOKEN;
20
+ const session = await createSession({
21
+ mcpServers: [
22
+ {
23
+ name: 'external',
24
+ type: 'http',
25
+ url,
26
+ headers: token
27
+ ? [{ name: 'Authorization', value: `Bearer ${token}` }]
28
+ : [],
29
+ },
30
+ ],
31
+ });
32
+
33
+ try {
34
+ const { servers } = await session.listMcpServers();
35
+ const tools = await session.listMcpTools();
36
+ const server = servers.find((item) => item.name === 'external');
37
+
38
+ console.log(`status: ${server?.status ?? 'unknown'}`);
39
+
40
+ for (const tool of tools.filter((item) => item.serverName === 'external')) {
41
+ console.log(tool.name);
42
+ }
43
+ } finally {
44
+ await session.close();
45
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Fork session example.
3
3
  *
4
- * Establishes context in one session, forks it with `fork()`, then
5
- * uses the returned fork handle and confirms the copied context carried over.
4
+ * Establishes context in one session, forks it with `fork()`, then uses the
5
+ * returned fork handle to show the copied context.
6
6
  *
7
7
  * Usage:
8
8
  * npx tsx examples/node/fork-session.ts
@@ -19,6 +19,12 @@ 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
+ }
25
+ }
26
+ if (!text.trim()) {
27
+ throw new Error('The turn produced no assistant output.');
22
28
  }
23
29
  return text;
24
30
  }
@@ -30,15 +36,20 @@ try {
30
36
 
31
37
  const fork = await session.fork();
32
38
 
33
- // `session` is now a retired wrapper. Its persisted ID can still be passed
34
- // to resumeSession() later if the original branch is needed again.
35
- console.log(`original session: ${session.id}`);
36
- console.log(`forked session: ${fork.id}`);
37
- console.log(
38
- `fork recalls: ${await streamText(fork, 'What phrase did I ask you to remember?')}`
39
- );
39
+ try {
40
+ // `session` is now a retired wrapper. Its persisted ID can still be passed
41
+ // to resumeSession() later if the original branch is needed again.
42
+ console.log(`original session: ${session.id}`);
43
+ console.log(`forked session: ${fork.id}`);
40
44
 
41
- await fork.close();
45
+ const recalled = await streamText(
46
+ fork,
47
+ 'What phrase did I ask you to remember?'
48
+ );
49
+ console.log(`fork recalls: ${recalled}`);
50
+ } finally {
51
+ await fork.close();
52
+ }
42
53
  } finally {
43
54
  // Closes the active source, or does nothing if it was retired.
44
55
  await session.close();
@@ -1,23 +1,9 @@
1
1
  /**
2
2
  * Hook execution example.
3
3
  *
4
- * Registers a PreToolUse hook for the `Execute` tool and prints the hook
5
- * lifecycle messages that arrive in the session stream.
6
- *
7
- * The hook lives in a throwaway project directory (`<tempdir>/.factory/hooks.json`)
8
- * that is passed as the session `cwd` and deleted afterwards, so running this
9
- * example never writes to your own `~/.factory` configuration. It does not
10
- * isolate you from it: hook lists are concatenated across settings levels
11
- * rather than replaced, so your own user-level `PreToolUse` hooks are appended
12
- * to the one below and fire during this run whenever their matcher matches.
13
- *
14
- * `hooksDisabled` resolves first-defined-wins across settings levels, so the
15
- * config below sets it explicitly rather than leaving it absent. An absent
16
- * value would inherit a global `"hooksDisabled": true` from `~/.factory` and
17
- * the hook would never run.
18
- *
19
- * See https://docs.factory.ai/cli/configuration/hooks-guide for the `/hooks`
20
- * slash command, the other events, and security guidance.
4
+ * Creates a temporary project with a PreToolUse hook and prints its stream
5
+ * events.
6
+ * User-level hooks still apply to the temporary project.
21
7
  *
22
8
  * Usage:
23
9
  * npx tsx examples/node/hook-execution.ts
@@ -30,6 +16,7 @@ import { join } from 'node:path';
30
16
  import {
31
17
  DroidMessageType,
32
18
  ToolConfirmationOutcome,
19
+ ToolConfirmationType,
33
20
  createSession,
34
21
  } from '@factory/droid-sdk/node';
35
22
 
@@ -46,6 +33,7 @@ const hookSettings = {
46
33
  const projectDir = await mkdtemp(join(tmpdir(), 'droid-sdk-hook-project-'));
47
34
 
48
35
  let hookCount = 0;
36
+ const expectedCommand = 'echo hello';
49
37
 
50
38
  try {
51
39
  await mkdir(join(projectDir, '.factory'), { recursive: true });
@@ -56,25 +44,43 @@ try {
56
44
 
57
45
  const session = await createSession({
58
46
  cwd: projectDir,
59
- permissionHandler: () => ToolConfirmationOutcome.ProceedOnce,
47
+ permissionHandler({ toolUses, options }) {
48
+ const approvesExpectedCommand =
49
+ toolUses.length === 1 &&
50
+ toolUses[0].details.type === ToolConfirmationType.Execute &&
51
+ toolUses[0].details.fullCommand === expectedCommand;
52
+ const desired = approvesExpectedCommand
53
+ ? ToolConfirmationOutcome.ProceedOnce
54
+ : ToolConfirmationOutcome.Cancel;
55
+
56
+ if (!options.some((option) => option.value === desired)) {
57
+ throw new Error(`Outcome not offered: ${desired}`);
58
+ }
59
+ return desired;
60
+ },
60
61
  });
61
62
 
62
63
  try {
63
- for await (const msg of session.stream(
64
- 'Run the shell command `echo hello` using the Execute tool.'
64
+ for await (const message of session.stream(
65
+ `Run the shell command \`${expectedCommand}\` using the Execute tool.`
65
66
  )) {
66
- if (msg.type !== DroidMessageType.Hook) {
67
- continue;
68
- }
69
-
70
- hookCount++;
71
- if (msg.status === 'started') {
72
- console.log(`[hook started] ${msg.eventName}: ${msg.command}`);
73
- } else {
74
- console.log(
75
- `[hook ${msg.status}] exit code ${String(msg.exitCode)}: ${(msg.stdout ?? '').trim()}`
67
+ if (message.type === DroidMessageType.Result && !message.success) {
68
+ throw new Error(
69
+ message.error?.message ?? `Turn ended: ${message.subtype}`
76
70
  );
77
71
  }
72
+ if (message.type === DroidMessageType.Hook) {
73
+ hookCount++;
74
+ if (message.status === 'started') {
75
+ console.log(
76
+ `[hook started] ${message.eventName}: ${message.command}`
77
+ );
78
+ } else {
79
+ console.log(
80
+ `[hook ${message.status}] exit code ${String(message.exitCode)}: ${(message.stdout ?? '').trim()}`
81
+ );
82
+ }
83
+ }
78
84
  }
79
85
  } finally {
80
86
  await session.close();
@@ -83,12 +89,6 @@ try {
83
89
  await rm(projectDir, { recursive: true, force: true });
84
90
  }
85
91
 
86
- // A silent zero-hook run would look identical to a passing one, so fail loudly.
87
92
  if (hookCount === 0) {
88
- throw new Error(
89
- 'No hook messages arrived: the registered PreToolUse hook never ran. ' +
90
- 'The most likely cause is that the droid answered this turn without ' +
91
- 'calling the Execute tool, so the `Execute` matcher never fired. ' +
92
- 'Re-run, or widen the matcher to the tool the droid actually used.'
93
- );
93
+ throw new Error('The PreToolUse hook did not run.');
94
94
  }
@@ -19,7 +19,7 @@ const PURPLE_SQUARE_PNG =
19
19
  'Bg0aNGjQoEGDFmjQAg0atECDFmjQoAUatECDBi3QoAUaNGiBBi3QoEELNGiBBn2g' +
20
20
  'AU2hHmFuN1AZAAAAAElFTkSuQmCC';
21
21
 
22
- const { text } = await run(
22
+ const result = await run(
23
23
  'What single shape and color is in this image? Answer in five words or fewer.',
24
24
  {
25
25
  images: [
@@ -32,4 +32,10 @@ const { text } = await run(
32
32
  }
33
33
  );
34
34
 
35
- console.log(text);
35
+ if (!result.success) {
36
+ throw new Error(
37
+ result.error?.message ?? `Image description failed (${result.subtype}).`
38
+ );
39
+ }
40
+
41
+ console.log(result.text);