@code-yeongyu/senpi 2026.10.24-3 → 2026.10.24-4

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 (78) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bundle/chunks/{args-6T5EQVY3.js → args-JXULI4EH.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-VUKNGMDT.js → chunk-3LNSLJ7N.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-MRN6IKOQ.js → chunk-3VOG6WFU.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-GOB5BLDV.js → chunk-42OY5SZZ.js} +2 -2
  6. package/dist/bundle/chunks/{chunk-4TRKE3ZC.js → chunk-BMYNMUVZ.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-JAXZOEJE.js → chunk-BWA4FVFW.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-AGQSRWNN.js → chunk-EHQXLHRB.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-YJA2B7FQ.js → chunk-FDL4CF6F.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-DITSGW4R.js → chunk-GVJ76VQI.js} +38 -38
  11. package/dist/bundle/chunks/{chunk-HCF5M6NA.js → chunk-GXY5JEI5.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-CYG4LS4S.js → chunk-HKUXKTFW.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-AVLYZET7.js → chunk-IUHU5PEC.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-LR6Z6V54.js → chunk-NQC7MI2G.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-DYWHYQUD.js → chunk-PXKF2RHD.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-H6IG2ITJ.js → chunk-QH2HSCMX.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-CZYADQ2E.js → chunk-SGDOZT6B.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-BZ75DRZB.js → chunk-U67ML6CQ.js} +4 -2
  19. package/dist/bundle/chunks/{chunk-I5NIJZDY.js → chunk-UT6AJHPK.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-CZR7C6CY.js → chunk-VOBD6FQG.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-SGATKHO4.js → chunk-Y5OTHMXO.js} +1 -1
  22. package/dist/bundle/chunks/{cli-main-BOYWBHOB.js → cli-main-UH3MAREK.js} +1 -1
  23. package/dist/bundle/chunks/{dispatch-KQEGIXQW.js → dispatch-DEP4PEDZ.js} +3 -2
  24. package/dist/bundle/chunks/{help-fast-path-6PVN7WI5.js → help-fast-path-ZHZGXAUG.js} +1 -1
  25. package/dist/bundle/chunks/{host-command-7MAXSZIH.js → host-command-OQR4RDTM.js} +1 -1
  26. package/dist/bundle/chunks/{host-session-command-L2UTORL5.js → host-session-command-IGB27ZPJ.js} +1 -1
  27. package/dist/bundle/chunks/{interactive-mode-NVBFOE5R.js → interactive-mode-R5RCSJ2F.js} +1 -1
  28. package/dist/bundle/chunks/{list-tips-BO5RP34W.js → list-tips-CREX4IXZ.js} +1 -1
  29. package/dist/bundle/chunks/{multi-session-host-3TVASSQD.js → multi-session-host-YZEMYUFY.js} +2 -2
  30. package/dist/bundle/chunks/{package-manager-cli-5BHVDVJC.js → package-manager-cli-5FOXDKUA.js} +1 -1
  31. package/dist/bundle/chunks/{role-BHA5ROSF.js → role-M2MTVACJ.js} +1 -1
  32. package/dist/bundle/chunks/{rotation-stream-RXMCJJUL.js → rotation-stream-U7YQMC7H.js} +1 -1
  33. package/dist/bundle/chunks/{rpc-mode-LPP46I7H.js → rpc-mode-6K62CZBW.js} +1 -1
  34. package/dist/bundle/chunks/{schedule-command-QW67J57R.js → schedule-command-XBW4EOG5.js} +1 -1
  35. package/dist/bundle/chunks/{session-control-endpoint-QQBBY5AT.js → session-control-endpoint-54TMCS7Q.js} +1 -1
  36. package/dist/bundle/chunks/{session-picker-HIC5ZTE6.js → session-picker-P2VUU2MQ.js} +1 -1
  37. package/dist/bundle/chunks/session-worker.js +56 -53
  38. package/dist/bundle/cli.js +2 -2
  39. package/dist/bundle/index.js +1 -1
  40. package/dist/bundle/rpc-entry.js +1 -1
  41. package/dist/bundle/runtime-manifest.json +1 -1
  42. package/dist/cli/args.d.ts +2 -0
  43. package/dist/cli/args.js +27 -0
  44. package/dist/core/agent-session.js +1 -1
  45. package/dist/core/extensions/builtin/anthropic-subscription/index.js +4 -2
  46. package/dist/core/extensions/builtin/anthropic-subscription/options.js +2 -1
  47. package/dist/core/extensions/builtin/anthropic-subscription/sdk-boundary.js +9 -2
  48. package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.d.ts +3 -0
  49. package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.js +9 -0
  50. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +1 -1
  51. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +3 -1
  52. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +4 -1
  53. package/dist/core/extensions/builtin/btw/index.js +4 -1
  54. package/dist/core/extensions/builtin/compaction/lane-policy.js +3 -1
  55. package/dist/core/extensions/builtin/look-at/runner.js +1 -0
  56. package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
  57. package/dist/core/extensions/builtin/mcp/connection.js +3 -0
  58. package/dist/core/extensions/builtin/mcp/elicitation.d.ts +5 -2
  59. package/dist/core/extensions/builtin/mcp/elicitation.js +57 -11
  60. package/dist/core/extensions/builtin/mcp/index.js +1 -1
  61. package/dist/core/extensions/builtin/mcp/modern/dispatch.js +6 -9
  62. package/dist/core/extensions/builtin/mcp/service-connection.d.ts +1 -0
  63. package/dist/core/extensions/builtin/mcp/service-connection.js +1 -0
  64. package/dist/core/extensions/builtin/mcp/service.js +1 -0
  65. package/dist/core/extensions/builtin/mcp/transport-sdk.d.ts +1 -0
  66. package/dist/core/extensions/builtin/mcp/transport-sdk.js +5 -5
  67. package/dist/core/extensions/builtin/mcp/transport.d.ts +1 -0
  68. package/dist/core/extensions/builtin/mcp/transport.js +1 -0
  69. package/dist/core/external-admission.d.ts +4 -1
  70. package/dist/core/external-admission.js +18 -2
  71. package/dist/core/lifecycle-session-binding.js +20 -0
  72. package/dist/core/sdk.js +1 -0
  73. package/dist/core/session-spawned-by.d.ts +4 -0
  74. package/dist/core/session-spawned-by.js +16 -0
  75. package/dist/main.js +7 -2
  76. package/dist/modes/rpc/session-router-open-profile.js +2 -10
  77. package/docs/usage.md +14 -0
  78. package/package.json +6 -6
@@ -3,9 +3,9 @@
3
3
  // The client declares the capability as an EMPTY object — Spring-AI servers
4
4
  // reject richer shapes (FieldCoding lesson from the comparison corpus) — and
5
5
  // answers elicitation/create requests mid tool-call by walking the requested
6
- // flat-primitive schema through sequential ctx.ui primitives (input/select/
7
- // confirm; NEVER ctx.ui.custom, which is undefined in RPC mode). Without a UI
8
- // (print mode) the request is declined immediately — spec-correct, no hang.
6
+ // flat-primitive schema through sequential ctx.ui primitives (input/select;
7
+ // NEVER ctx.ui.custom, which is undefined in RPC mode). Without a UI
8
+ // (print mode) the request fails with a processing error, never a user refusal.
9
9
  // The whole form is bounded by a timeout that resolves to cancel so a wedged
10
10
  // dialog can never wedge the tool-call pipeline. URL mode is deliberately not
11
11
  // implemented in v1.
@@ -14,28 +14,46 @@ import { loadMcpSdkTypes } from "./sdk.lazy.js";
14
14
  import { safeTimer } from "./wrap.js";
15
15
  /** Declared empty on purpose: maximum server compatibility (form mode only). */
16
16
  export const MCP_CLIENT_ELICITATION_CAPABILITY = { elicitation: {} };
17
+ export function mcpClientCapabilities(elicitationEnabled = true) {
18
+ return elicitationEnabled ? MCP_CLIENT_ELICITATION_CAPABILITY : {};
19
+ }
17
20
  export const MCP_ELICITATION_TIMEOUT_MS = 5 * 60 * 1000;
18
21
  let legacyUiProvider;
19
22
  /** Legacy classic-session seam. Session-owned services pass themselves directly. */
20
23
  export function setMcpElicitationUiProvider(provider) {
21
24
  legacyUiProvider = provider;
22
25
  }
26
+ async function rejectElicitation() {
27
+ const { ErrorCode, McpError } = await loadMcpSdkTypes();
28
+ throw new McpError(ErrorCode.InternalError, "Elicitation cannot be processed");
29
+ }
23
30
  /**
24
31
  * Wire the elicitation/create handler onto a fresh client. Async because the
25
32
  * request schema comes from the lazily loaded SDK; the caller (`buildMcpClient`
26
33
  * in `transport-sdk.ts`) awaits it before any connect, so a mid-call
27
34
  * elicitation still cannot race registration.
28
35
  */
29
- export async function configureMcpElicitation(client, owner = legacyUiProvider, timeoutMs = MCP_ELICITATION_TIMEOUT_MS) {
36
+ export async function configureMcpElicitation(client, owner = legacyUiProvider, timeoutMs = MCP_ELICITATION_TIMEOUT_MS, elicitationEnabled = true) {
37
+ if (!elicitationEnabled) {
38
+ // The SDK requires an advertised capability for setRequestHandler.
39
+ // Its public fallback handles unsolicited requests without claiming UI support.
40
+ const { ErrorCode, McpError } = await loadMcpSdkTypes();
41
+ client.fallbackRequestHandler = async (request) => {
42
+ if (request.method === "elicitation/create")
43
+ return rejectElicitation();
44
+ throw new McpError(ErrorCode.MethodNotFound, "Method not found");
45
+ };
46
+ return;
47
+ }
30
48
  const { ElicitRequestSchema } = await loadMcpSdkTypes();
31
49
  client.setRequestHandler(ElicitRequestSchema, async (request) => {
32
50
  const params = request.params;
33
51
  // URL mode is deliberately unsupported in v1 (form mode only).
34
52
  if (!("requestedSchema" in params))
35
- return { action: "decline" };
53
+ return rejectElicitation();
36
54
  const ui = typeof owner === "function" ? owner() : owner?.getMcpElicitationUi();
37
55
  if (ui === undefined)
38
- return { action: "decline" };
56
+ return rejectElicitation();
39
57
  return await runElicitationForm(ui, params.message, params.requestedSchema, timeoutMs);
40
58
  });
41
59
  }
@@ -61,31 +79,59 @@ async function collectForm(ui, message, requestedSchema) {
61
79
  for (const [name, property] of Object.entries(requestedSchema.properties ?? {})) {
62
80
  const title = `${message} - ${property.title ?? name}`;
63
81
  if (property.type === "boolean") {
64
- content[name] = await ui.confirm(title, property.description ?? name);
82
+ // confirm collapses cancellation into false, including automated RPC replies.
83
+ // A selection preserves dismissal separately from a human's explicit No.
84
+ const picked = await ui.select(property.description ? `${title}\n${property.description}` : title, [
85
+ "Yes",
86
+ "No",
87
+ ]);
88
+ if (picked === undefined)
89
+ return { action: "cancel" };
90
+ // The thread gateway also accepts y/n and true/false, ignoring case and whitespace.
91
+ switch (picked.trim().toLowerCase()) {
92
+ case "yes":
93
+ case "y":
94
+ case "true":
95
+ content[name] = true;
96
+ break;
97
+ case "no":
98
+ case "n":
99
+ case "false":
100
+ content[name] = false;
101
+ break;
102
+ default:
103
+ return rejectElicitation();
104
+ }
65
105
  continue;
66
106
  }
67
107
  if (Array.isArray(property.enum) && property.enum.length > 0) {
68
108
  const picked = await ui.select(title, property.enum.map(String));
69
109
  if (picked === undefined)
70
- return { action: "decline" };
110
+ return { action: "cancel" };
71
111
  content[name] = picked;
72
112
  continue;
73
113
  }
74
114
  const answer = await ui.input(title, property.description);
75
- if (answer === undefined || answer.length === 0) {
115
+ if (answer === undefined)
116
+ return { action: "cancel" };
117
+ if (answer.length === 0) {
76
118
  if (required.has(name))
77
- return { action: "decline" };
119
+ return rejectElicitation();
78
120
  continue;
79
121
  }
80
122
  if (property.type === "number" || property.type === "integer") {
81
123
  const parsed = Number(answer);
82
124
  if (!Number.isFinite(parsed))
83
- return { action: "decline" };
125
+ return rejectElicitation();
84
126
  content[name] = parsed;
85
127
  continue;
86
128
  }
87
129
  content[name] = answer;
88
130
  }
131
+ // Even a fieldless form needs an explicit submission rather than an automatic accept.
132
+ if (Object.keys(content).length === 0 && (await ui.select(message, ["Submit"])) !== "Submit") {
133
+ return { action: "cancel" };
134
+ }
89
135
  return { action: "accept", content };
90
136
  }
91
137
  //# sourceMappingURL=elicitation.js.map
@@ -123,7 +123,7 @@ export function createMcpExtension(service, sessionOwned = true) {
123
123
  }
124
124
  else {
125
125
  // Elicitation (todo 41): point mid-call forms at this session's UI.
126
- service.setMcpElicitationUiProvider(() => ctx.ui);
126
+ service.setMcpElicitationUiProvider(() => (ctx.hasUI ? ctx.ui : undefined));
127
127
  await (attachPromise ?? attach({ type: "session_start", reason: "startup" }, ctx));
128
128
  }
129
129
  const skills = preview ? [] : (event.systemPromptOptions.skills ?? []);
@@ -1,4 +1,4 @@
1
- import { configureMcpElicitation, MCP_CLIENT_ELICITATION_CAPABILITY } from "../elicitation.js";
1
+ import { configureMcpElicitation, mcpClientCapabilities } from "../elicitation.js";
2
2
  import { ConnectError } from "../errors.js";
3
3
  import { loadMcpSdkClient } from "../sdk.lazy.js";
4
4
  import { materializeMcpTransport, } from "../transport-sdk.js";
@@ -9,13 +9,10 @@ import { probeStdioTransport } from "./stdio-probe.js";
9
9
  import { ModernHttpTransport } from "./transport.js";
10
10
  import { MODERN_PROTOCOL_VERSION } from "./wire.js";
11
11
  const clientInfo = { name: "senpi-mcp-client", version: "0.0.0" };
12
- const probeOptions = {
13
- clientInfo,
14
- clientCapabilities: MCP_CLIENT_ELICITATION_CAPABILITY,
15
- supportedVersions: [MODERN_PROTOCOL_VERSION],
16
- };
17
12
  export async function materializeNegotiatedTransport(options, serverName, timeoutMs, signal) {
18
13
  signal.throwIfAborted();
14
+ const clientCapabilities = mcpClientCapabilities(options.elicitationEnabled);
15
+ const probeOptions = { clientInfo, clientCapabilities, supportedVersions: [MODERN_PROTOCOL_VERSION] };
19
16
  const { spec, logger } = options;
20
17
  let outcome = { kind: "legacy-fallback" };
21
18
  if (spec.kind === "http") {
@@ -32,13 +29,13 @@ export async function materializeNegotiatedTransport(options, serverName, timeou
32
29
  });
33
30
  }
34
31
  const { Client } = await loadMcpSdkClient();
35
- const client = new Client(clientInfo, { capabilities: MCP_CLIENT_ELICITATION_CAPABILITY });
36
- await configureMcpElicitation(client, options.elicitationUiProvider);
32
+ const client = new Client(clientInfo, { capabilities: clientCapabilities });
33
+ await configureMcpElicitation(client, options.elicitationUiProvider, undefined, options.elicitationEnabled);
37
34
  const transport = new ModernSdkBridge(new ModernHttpTransport({
38
35
  url: spec.url,
39
36
  fetch,
40
37
  clientInfo,
41
- clientCapabilities: MCP_CLIENT_ELICITATION_CAPABILITY,
38
+ clientCapabilities,
42
39
  protocolVersion: outcome.version,
43
40
  }), outcome.discovery, outcome.version);
44
41
  const version = outcome.version;
@@ -14,6 +14,7 @@ interface SessionConnectionOptions {
14
14
  readonly generation: McpConfigGeneration;
15
15
  readonly declared: McpServerDeclaredConfig;
16
16
  readonly session: McpSessionOptions;
17
+ readonly elicitationEnabled: boolean;
17
18
  readonly cwd: string;
18
19
  readonly ui: McpElicitationUiProvider;
19
20
  readonly artifacts: McpOutputArtifacts;
@@ -38,6 +38,7 @@ export function createMcpSessionConnection(options) {
38
38
  config,
39
39
  env: projectPolicy?.initial.env ?? session.env,
40
40
  elicitationUiProvider: options.ui,
41
+ elicitationEnabled: options.elicitationEnabled,
41
42
  logger,
42
43
  serverName: name,
43
44
  ...(renewal === undefined ? {} : { resolveConfig: renewal.resolveConfig, onGenerationChanged: rekey }),
@@ -709,6 +709,7 @@ export class McpService {
709
709
  await this.#resyncToLiveSessions(owner);
710
710
  },
711
711
  session: options,
712
+ elicitationEnabled: declarer.context.mode !== "print" && declarer.context.mode !== "json",
712
713
  cwd: declarer.context.cwd,
713
714
  ui: () => this.getMcpElicitationUi(),
714
715
  artifacts: this.#outputArtifacts,
@@ -44,6 +44,7 @@ export interface MaterializeMcpTransportOptions {
44
44
  readonly logger: McpLogger;
45
45
  readonly sink: McpAsyncErrorSink;
46
46
  readonly elicitationUiProvider: McpElicitationUiProvider | undefined;
47
+ readonly elicitationEnabled?: boolean;
47
48
  /** Invoked once the stdio child has started, so the caller can capture its pid. */
48
49
  readonly onStart: () => void;
49
50
  }
@@ -1,9 +1,9 @@
1
- import { configureMcpElicitation, MCP_CLIENT_ELICITATION_CAPABILITY, } from "./elicitation.js";
1
+ import { configureMcpElicitation, mcpClientCapabilities } from "./elicitation.js";
2
2
  import { ProjectStdioTransport } from "./project-stdio-transport.js";
3
3
  import { loadMcpSdkClient, loadMcpSdkStdioTransport, loadMcpSdkStreamableHttpTransport } from "./sdk.lazy.js";
4
4
  import { safeOn } from "./wrap.js";
5
5
  export async function materializeMcpTransport(options) {
6
- const client = await buildMcpClient(options.elicitationUiProvider);
6
+ const client = await buildMcpClient(options.elicitationUiProvider, options.elicitationEnabled);
7
7
  if (options.spec.kind === "stdio")
8
8
  return materializeStdio(options.spec, options, client);
9
9
  return materializeHttp(options.spec, client);
@@ -48,13 +48,13 @@ async function materializeHttp(spec, client) {
48
48
  transport,
49
49
  };
50
50
  }
51
- async function buildMcpClient(elicitationUiProvider) {
51
+ async function buildMcpClient(elicitationUiProvider, elicitationEnabled = true) {
52
52
  // Elicitation capability is declared EMPTY on purpose (form mode only;
53
53
  // Spring-AI servers reject richer shapes) and the create-handler is wired
54
54
  // before any connect so mid-call requests never race registration.
55
55
  const { Client: McpClient } = await loadMcpSdkClient();
56
- const client = new McpClient({ name: "senpi-mcp-client", version: "0.0.0" }, { capabilities: MCP_CLIENT_ELICITATION_CAPABILITY });
57
- await configureMcpElicitation(client, elicitationUiProvider);
56
+ const client = new McpClient({ name: "senpi-mcp-client", version: "0.0.0" }, { capabilities: mcpClientCapabilities(elicitationEnabled) });
57
+ await configureMcpElicitation(client, elicitationUiProvider, undefined, elicitationEnabled);
58
58
  return client;
59
59
  }
60
60
  // OMP pattern: hand stdio OAuth servers the current access token via env.
@@ -32,6 +32,7 @@ export type CreateMcpTransportOptions = {
32
32
  readonly logger: McpLogger;
33
33
  readonly env?: Record<string, string | undefined>;
34
34
  readonly elicitationUiProvider?: McpElicitationUiProvider;
35
+ readonly elicitationEnabled?: boolean;
35
36
  readonly authProvider?: McpOAuthProvider;
36
37
  /** Re-reads a declaring server's references for this transport (senpi#3858). */
37
38
  readonly resolveConfig?: () => Promise<McpServerConfig>;
@@ -91,6 +91,7 @@ function createConnection(options, spec, connectTimeoutMs) {
91
91
  materialize: async () => {
92
92
  const materializeWith = (current) => import("./modern/dispatch.js").then(({ materializeNegotiatedTransport }) => materializeNegotiatedTransport({
93
93
  elicitationUiProvider: options.elicitationUiProvider,
94
+ elicitationEnabled: options.elicitationEnabled,
94
95
  logger: options.logger,
95
96
  onStart: captureRootPid,
96
97
  sink: asyncErrorSink,
@@ -60,6 +60,8 @@ export declare class ExternalAdmission {
60
60
  }): InputHold;
61
61
  onInputsSettled(listener: () => void): () => void;
62
62
  onEmitted(listener: (deliveryId: string) => void): () => void;
63
+ /** Durable custody will replay this delivery: reserve its id before asynchronous recovery or gateway admission. */
64
+ restoreQueued(deliveryId: string, isOwned: () => boolean): void;
63
65
  /**
64
66
  * From here on every `admit` throws `reason` and changes nothing: the runtime is being handed to
65
67
  * another writer, so the delivery stays with its sender and is delivered there instead.
@@ -82,8 +84,9 @@ export declare class ExternalAdmission {
82
84
  * deliveries may be admitted again - unless the file's last write was refused too.
83
85
  */
84
86
  observeRunSettled(): void;
85
- /** The runtime's queues were cleared: queued deliveries are no longer held. */
87
+ /** Cleared carriers are no longer held; custody not yet enqueued for recovery still owns its delivery. */
86
88
  dropQueued(): void;
89
+ private releaseRestored;
87
90
  private endHold;
88
91
  private isBusy;
89
92
  }
@@ -54,6 +54,13 @@ export class ExternalAdmission {
54
54
  this.emittedListeners.add(listener);
55
55
  return () => this.emittedListeners.delete(listener);
56
56
  }
57
+ /** Durable custody will replay this delivery: reserve its id before asynchronous recovery or gateway admission. */
58
+ restoreQueued(deliveryId, isOwned) {
59
+ if (this.pending.has(deliveryId) || this.emitted.has(deliveryId) || this.failed.has(deliveryId))
60
+ return;
61
+ // Recovery chooses the payload's queue lane; this reservation only tracks durable ownership.
62
+ this.pending.set(deliveryId, isOwned);
63
+ }
57
64
  /**
58
65
  * From here on every `admit` throws `reason` and changes nothing: the runtime is being handed to
59
66
  * another writer, so the delivery stays with its sender and is delivered there instead.
@@ -79,6 +86,7 @@ export class ExternalAdmission {
79
86
  throw new Error(this.closedReason);
80
87
  const turn_epoch = this.turnEpochValue;
81
88
  const id = input.delivery_id;
89
+ this.releaseRestored();
82
90
  if (this.pending.has(id) || this.emitted.has(id) || this.failed.has(id)) {
83
91
  return { kind: "already_admitted", turn_epoch };
84
92
  }
@@ -112,6 +120,7 @@ export class ExternalAdmission {
112
120
  return { kind: "queued", turn_epoch };
113
121
  }
114
122
  list() {
123
+ this.releaseRestored();
115
124
  const deliveries = { pending: [...this.pending.keys()], emitted: [...this.emitted] };
116
125
  if (this.failed.size === 0)
117
126
  return deliveries;
@@ -149,10 +158,17 @@ export class ExternalAdmission {
149
158
  if (this.fileTakesWrites)
150
159
  this.failed.clear();
151
160
  }
152
- /** The runtime's queues were cleared: queued deliveries are no longer held. */
161
+ /** Cleared carriers are no longer held; custody not yet enqueued for recovery still owns its delivery. */
153
162
  dropQueued() {
163
+ this.releaseRestored();
164
+ for (const [id, lane] of this.pending) {
165
+ if (lane !== "start" && typeof lane !== "function")
166
+ this.pending.delete(id);
167
+ }
168
+ }
169
+ releaseRestored() {
154
170
  for (const [id, lane] of this.pending) {
155
- if (lane !== "start")
171
+ if (typeof lane === "function" && !lane())
156
172
  this.pending.delete(id);
157
173
  }
158
174
  }
@@ -2,6 +2,7 @@ import { collectGoalLifecycleFacts, onGoalLifecycleChange } from "./extensions/b
2
2
  import { goalStoreRef } from "./extensions/builtin/goal/store-ref.js";
3
3
  import { collectTurnEndLivenessInput } from "./extensions/builtin/goal/turn-end-liveness-report.js";
4
4
  import { StaleExtensionContextError } from "./extensions/stale-context-error.js";
5
+ import { deliveryIdOf } from "./external-admission.js";
5
6
  import { consumeLifecycleDelivery, lifecycleDeliveryEntries, messageTicket } from "./lifecycle-delivery.js";
6
7
  import { readLifecycleGoal } from "./lifecycle-goal.js";
7
8
  import { lifecycleHistory } from "./lifecycle-history.js";
@@ -41,6 +42,25 @@ export class LifecycleSessionBinding {
41
42
  },
42
43
  readRuntime: () => readLifecycleRuntimeFacts(session),
43
44
  });
45
+ // A crashed receiver may have durable custody but no transcript entry yet. Recovery owns that
46
+ // delivery already; register it before session_start can let the gateway enqueue a second carrier.
47
+ const deliveryEntries = session.sessionManager.reduceHistory(lifecycleDeliveryEntries);
48
+ for (const record of this.controller.obligations.active) {
49
+ if (record.state !== "queued" || !record.payload || !record.ticketId)
50
+ continue;
51
+ const ticketId = record.ticketId;
52
+ const message = { role: "custom", ...record.payload.message, timestamp: 0 };
53
+ const deliveryId = deliveryIdOf(message);
54
+ if (deliveryId === undefined)
55
+ continue;
56
+ if (deliveryEntries.has(ticketId))
57
+ session.externalAdmission.observePersisted(message);
58
+ else
59
+ session.externalAdmission.restoreQueued(deliveryId, () =>
60
+ // Consumption writes the entry before message_end observes it in external admission.
61
+ session.sessionManager.reduceHistory(lifecycleDeliveryEntries).has(ticketId) ||
62
+ this.controller.obligations.active.some((current) => current.ticketId === ticketId && current.state === "queued"));
63
+ }
44
64
  this.unsubscribeHuman = bindLifecycleHumanInput(session, (event) => this.controller.humanInput(event.at));
45
65
  this.unsubscribeAdmission = session.externalAdmission.onInputsSettled(() => this.controller.admissionSettled());
46
66
  this.unsubscribeGoal = onGoalLifecycleChange(session.sessionManager, (goal) => {
package/dist/core/sdk.js CHANGED
@@ -315,6 +315,7 @@ export async function createAgentSession(options = {}) {
315
315
  const serviceTier = serviceTierForProvider(model.provider, options?.serviceTier ?? resolveRequestServiceTier(model));
316
316
  return modelRuntime.streamSimple(model, context, {
317
317
  ...options,
318
+ cwd,
318
319
  serviceTier,
319
320
  timeoutMs,
320
321
  websocketConnectTimeoutMs,
@@ -0,0 +1,4 @@
1
+ import type { SpawnedBy } from "./session-lifecycle-types.ts";
2
+ /** The shared CLI / open_session boundary contract; IDs are opaque, non-empty strings. */
3
+ export declare function isSpawnedBy(value: unknown): value is SpawnedBy;
4
+ //# sourceMappingURL=session-spawned-by.d.ts.map
@@ -0,0 +1,16 @@
1
+ /** The shared CLI / open_session boundary contract; IDs are opaque, non-empty strings. */
2
+ export function isSpawnedBy(value) {
3
+ return (typeof value === "object" &&
4
+ value !== null &&
5
+ !Array.isArray(value) &&
6
+ "parentSessionId" in value &&
7
+ typeof value.parentSessionId === "string" &&
8
+ value.parentSessionId.length > 0 &&
9
+ "rootSessionId" in value &&
10
+ typeof value.rootSessionId === "string" &&
11
+ value.rootSessionId.length > 0 &&
12
+ "taskId" in value &&
13
+ typeof value.taskId === "string" &&
14
+ value.taskId.length > 0);
15
+ }
16
+ //# sourceMappingURL=session-spawned-by.js.map
package/dist/main.js CHANGED
@@ -401,8 +401,11 @@ async function sessionCwdOrUndefined(sessionFile) {
401
401
  }
402
402
  }
403
403
  export async function createSessionManager(parsed, cwd, sessionDir, settingsManager, appMode) {
404
+ if (parsed.spawnedBy && (parsed.session || parsed.resume || parsed.continue || parsed.fork || parsed.rebind)) {
405
+ console.error(chalk.yellow("Warning: --spawned-by is ignored when resuming or forking a session."));
406
+ }
404
407
  if (parsed.noSession || parsed.help || parsed.listModels !== undefined || parsed.listTips) {
405
- return SessionManager.inMemory(cwd, parsed.sessionId !== undefined ? { id: parsed.sessionId } : undefined);
408
+ return SessionManager.inMemory(cwd, { id: parsed.sessionId, spawnedBy: parsed.spawnedBy });
406
409
  }
407
410
  if (parsed.fork) {
408
411
  if (parsed.sessionId) {
@@ -533,11 +536,13 @@ export async function createSessionManager(parsed, cwd, sessionDir, settingsMana
533
536
  if (parsed.sessionId) {
534
537
  const existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);
535
538
  if (existingSession) {
539
+ if (parsed.spawnedBy)
540
+ console.error(chalk.yellow("Warning: --spawned-by is ignored when resuming an existing session."));
536
541
  return SessionManager.open(existingSession.path, sessionDir);
537
542
  }
538
543
  console.error(chalk.yellow(`Warning: No project session found with id '${parsed.sessionId}'; creating a new session with that id.`));
539
544
  }
540
- return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId });
545
+ return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId, spawnedBy: parsed.spawnedBy });
541
546
  }
542
547
  function buildSessionOptions(parsed, scopedModels, hasExistingSession, modelRuntime, settingsManager) {
543
548
  const options = {};
@@ -1,3 +1,4 @@
1
+ import { isSpawnedBy } from "../../core/session-spawned-by.js";
1
2
  import { HOST_DAEMON_DIR_ENV } from "./host-daemon-paths.js";
2
3
  import { hostInstanceId } from "./protocol-identity.js";
3
4
  import { sessionAutoTitleError, sessionBrowserEngineError, sessionContextError, sessionKindError, sessionPromptSurfaceError, sessionRetryFallbackError, } from "./rpc-input-validation.js";
@@ -6,16 +7,7 @@ import { error } from "./session-router-errors.js";
6
7
  /** `open_session.spawnedBy` must name the parent, root and task when present; the open answers `invalid_spawned_by` otherwise. */
7
8
  export function invalidSpawnedBy(command) {
8
9
  const ancestry = command.spawnedBy;
9
- return (ancestry !== undefined &&
10
- (typeof ancestry !== "object" ||
11
- ancestry === null ||
12
- Array.isArray(ancestry) ||
13
- typeof ancestry.parentSessionId !== "string" ||
14
- ancestry.parentSessionId.length === 0 ||
15
- typeof ancestry.rootSessionId !== "string" ||
16
- ancestry.rootSessionId.length === 0 ||
17
- typeof ancestry.taskId !== "string" ||
18
- ancestry.taskId.length === 0));
10
+ return ancestry !== undefined && !isSpawnedBy(ancestry);
19
11
  }
20
12
  export function validatedOpenProfile(host, command, options) {
21
13
  // A parked session's reopen replays what its park recorded; a field it left empty stays empty
package/docs/usage.md CHANGED
@@ -232,12 +232,26 @@ cat README.md | senpi -p "Summarize this text"
232
232
  | `-c`, `--continue` | Continue the most recent session |
233
233
  | `-r`, `--resume` | Browse and select a session |
234
234
  | `--session <path\|id>` | Use a specific session file or partial UUID |
235
+ | `--spawned-by <json>` | Set parent, root, and task ancestry on a fresh standalone session |
235
236
  | `--fork <path\|id>` | Fork a session file or partial UUID into a new session |
236
237
  | `--rebind <path\|id>` | Move a session of this repository, recorded at another path (moved or re-cloned), into this directory and continue it |
237
238
  | `--session-dir <dir>` | Custom session storage directory |
238
239
  | `--no-session` | Ephemeral mode; do not save |
239
240
  | `--name <name>`, `-n <name>` | Set session display name at startup |
240
241
 
242
+ To start a standalone child session, pass one JSON object:
243
+
244
+ ```bash
245
+ senpi --mode rpc --spawned-by '{"parentSessionId":"parent","rootSessionId":"root","taskId":"task"}'
246
+ ```
247
+
248
+ All three fields must be non-empty strings; malformed input exits before runtime creation.
249
+ The ancestry is stored in the session header, so `get_state.lifecycle.role` is `child` before
250
+ the first prompt. A fresh session created with `--session-id` also accepts it. Resuming an
251
+ existing ID preserves its header and warns that the flag is ignored. The flag is likewise
252
+ ignored with a warning with `--session`, `--resume`, `--continue`, `--rebind`, or `--fork`.
253
+ Without the flag, new sessions and ordinary forks start as roots.
254
+
241
255
  ### Tool Options
242
256
 
243
257
  | Option | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.24-3",
3
+ "version": "2026.10.24-4",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -71,9 +71,9 @@
71
71
  },
72
72
  "dependencies": {
73
73
  "@earendil-works/chord": "0.99.1",
74
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.24-3",
75
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.24-3",
76
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.24-3",
74
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.24-4",
75
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.24-4",
76
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.24-4",
77
77
  "@silvia-odwyer/photon-node": "0.3.4",
78
78
  "chalk": "6.0.0",
79
79
  "cross-spawn": "7.0.6",
@@ -92,8 +92,8 @@
92
92
  "yaml": "2.9.1",
93
93
  "@anthropic-ai/claude-agent-sdk": "0.3.306",
94
94
  "@anthropic-ai/sdk": "0.127.0",
95
- "@code-yeongyu/senpi-codemode": "2026.10.24-3",
96
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.24-3",
95
+ "@code-yeongyu/senpi-codemode": "2026.10.24-4",
96
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.24-4",
97
97
  "@modelcontextprotocol/sdk": "1.32.1",
98
98
  "@mozilla/readability": "0.6.0",
99
99
  "linkedom": "0.18.13",