@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.
- package/CHANGELOG.md +22 -0
- package/dist/bundle/chunks/{args-6T5EQVY3.js → args-JXULI4EH.js} +1 -1
- package/dist/bundle/chunks/{chunk-VUKNGMDT.js → chunk-3LNSLJ7N.js} +1 -1
- package/dist/bundle/chunks/{chunk-MRN6IKOQ.js → chunk-3VOG6WFU.js} +1 -1
- package/dist/bundle/chunks/{chunk-GOB5BLDV.js → chunk-42OY5SZZ.js} +2 -2
- package/dist/bundle/chunks/{chunk-4TRKE3ZC.js → chunk-BMYNMUVZ.js} +1 -1
- package/dist/bundle/chunks/{chunk-JAXZOEJE.js → chunk-BWA4FVFW.js} +1 -1
- package/dist/bundle/chunks/{chunk-AGQSRWNN.js → chunk-EHQXLHRB.js} +1 -1
- package/dist/bundle/chunks/{chunk-YJA2B7FQ.js → chunk-FDL4CF6F.js} +1 -1
- package/dist/bundle/chunks/{chunk-DITSGW4R.js → chunk-GVJ76VQI.js} +38 -38
- package/dist/bundle/chunks/{chunk-HCF5M6NA.js → chunk-GXY5JEI5.js} +1 -1
- package/dist/bundle/chunks/{chunk-CYG4LS4S.js → chunk-HKUXKTFW.js} +1 -1
- package/dist/bundle/chunks/{chunk-AVLYZET7.js → chunk-IUHU5PEC.js} +1 -1
- package/dist/bundle/chunks/{chunk-LR6Z6V54.js → chunk-NQC7MI2G.js} +1 -1
- package/dist/bundle/chunks/{chunk-DYWHYQUD.js → chunk-PXKF2RHD.js} +1 -1
- package/dist/bundle/chunks/{chunk-H6IG2ITJ.js → chunk-QH2HSCMX.js} +1 -1
- package/dist/bundle/chunks/{chunk-CZYADQ2E.js → chunk-SGDOZT6B.js} +1 -1
- package/dist/bundle/chunks/{chunk-BZ75DRZB.js → chunk-U67ML6CQ.js} +4 -2
- package/dist/bundle/chunks/{chunk-I5NIJZDY.js → chunk-UT6AJHPK.js} +1 -1
- package/dist/bundle/chunks/{chunk-CZR7C6CY.js → chunk-VOBD6FQG.js} +1 -1
- package/dist/bundle/chunks/{chunk-SGATKHO4.js → chunk-Y5OTHMXO.js} +1 -1
- package/dist/bundle/chunks/{cli-main-BOYWBHOB.js → cli-main-UH3MAREK.js} +1 -1
- package/dist/bundle/chunks/{dispatch-KQEGIXQW.js → dispatch-DEP4PEDZ.js} +3 -2
- package/dist/bundle/chunks/{help-fast-path-6PVN7WI5.js → help-fast-path-ZHZGXAUG.js} +1 -1
- package/dist/bundle/chunks/{host-command-7MAXSZIH.js → host-command-OQR4RDTM.js} +1 -1
- package/dist/bundle/chunks/{host-session-command-L2UTORL5.js → host-session-command-IGB27ZPJ.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-NVBFOE5R.js → interactive-mode-R5RCSJ2F.js} +1 -1
- package/dist/bundle/chunks/{list-tips-BO5RP34W.js → list-tips-CREX4IXZ.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-3TVASSQD.js → multi-session-host-YZEMYUFY.js} +2 -2
- package/dist/bundle/chunks/{package-manager-cli-5BHVDVJC.js → package-manager-cli-5FOXDKUA.js} +1 -1
- package/dist/bundle/chunks/{role-BHA5ROSF.js → role-M2MTVACJ.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-RXMCJJUL.js → rotation-stream-U7YQMC7H.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-LPP46I7H.js → rpc-mode-6K62CZBW.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-QW67J57R.js → schedule-command-XBW4EOG5.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-QQBBY5AT.js → session-control-endpoint-54TMCS7Q.js} +1 -1
- package/dist/bundle/chunks/{session-picker-HIC5ZTE6.js → session-picker-P2VUU2MQ.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +56 -53
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +27 -0
- package/dist/core/agent-session.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/index.js +4 -2
- package/dist/core/extensions/builtin/anthropic-subscription/options.js +2 -1
- package/dist/core/extensions/builtin/anthropic-subscription/sdk-boundary.js +9 -2
- package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.d.ts +3 -0
- package/dist/core/extensions/builtin/anthropic-subscription/session-cwd.js +9 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +3 -1
- package/dist/core/extensions/builtin/anthropic-subscription/stream.js +4 -1
- package/dist/core/extensions/builtin/btw/index.js +4 -1
- package/dist/core/extensions/builtin/compaction/lane-policy.js +3 -1
- package/dist/core/extensions/builtin/look-at/runner.js +1 -0
- package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
- package/dist/core/extensions/builtin/mcp/connection.js +3 -0
- package/dist/core/extensions/builtin/mcp/elicitation.d.ts +5 -2
- package/dist/core/extensions/builtin/mcp/elicitation.js +57 -11
- package/dist/core/extensions/builtin/mcp/index.js +1 -1
- package/dist/core/extensions/builtin/mcp/modern/dispatch.js +6 -9
- package/dist/core/extensions/builtin/mcp/service-connection.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/service-connection.js +1 -0
- package/dist/core/extensions/builtin/mcp/service.js +1 -0
- package/dist/core/extensions/builtin/mcp/transport-sdk.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/transport-sdk.js +5 -5
- package/dist/core/extensions/builtin/mcp/transport.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/transport.js +1 -0
- package/dist/core/external-admission.d.ts +4 -1
- package/dist/core/external-admission.js +18 -2
- package/dist/core/lifecycle-session-binding.js +20 -0
- package/dist/core/sdk.js +1 -0
- package/dist/core/session-spawned-by.d.ts +4 -0
- package/dist/core/session-spawned-by.js +16 -0
- package/dist/main.js +7 -2
- package/dist/modes/rpc/session-router-open-profile.js +2 -10
- package/docs/usage.md +14 -0
- 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
|
-
//
|
|
8
|
-
// (print mode) the request
|
|
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
|
|
53
|
+
return rejectElicitation();
|
|
36
54
|
const ui = typeof owner === "function" ? owner() : owner?.getMcpElicitationUi();
|
|
37
55
|
if (ui === undefined)
|
|
38
|
-
return
|
|
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
|
-
|
|
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: "
|
|
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
|
|
115
|
+
if (answer === undefined)
|
|
116
|
+
return { action: "cancel" };
|
|
117
|
+
if (answer.length === 0) {
|
|
76
118
|
if (required.has(name))
|
|
77
|
-
return
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
"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-
|
|
75
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.24-
|
|
76
|
-
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.24-
|
|
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-
|
|
96
|
-
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.24-
|
|
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",
|