thincoder 0.12.62 → 0.12.64
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 +27 -0
- package/README.md +46 -49
- package/bin/thincoder.mjs +68 -34
- package/package.json +7 -6
- package/src/acp/bridge.mjs +35 -15
- package/src/acp/client-caps.mjs +86 -0
- package/src/acp/ext.mjs +86 -0
- package/src/acp/handlers-session.mjs +257 -0
- package/src/acp/handlers-slots.mjs +196 -0
- package/src/acp/login.mjs +48 -0
- package/src/acp/session.mjs +6 -4
- package/src/acp.mjs +67 -379
- package/src/cli/distill-command.mjs +3 -3
- package/src/cli/make-agent.mjs +60 -17
- package/src/cli/memory-command.mjs +3 -3
- package/src/cli/permission.mjs +4 -48
- package/src/cli/setup-wizard.mjs +1 -1
- package/src/completions.mjs +3 -1
- package/src/crash-reports.mjs +1 -1
- package/src/distill.mjs +4 -4
- package/src/heap-watch.mjs +1 -1
- package/src/prompt-injections.mjs +20 -0
- package/src/tui/agent-turn.mjs +40 -9
- package/src/tui/cmd-advisor.mjs +5 -5
- package/src/tui/cmd-config.mjs +8 -8
- package/src/tui/cmd-eng.mjs +35 -9
- package/src/tui/cmd-mcp.mjs +9 -8
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +6 -5
- package/src/tui/cmd-plan.mjs +9 -0
- package/src/tui/cmd-reindex.mjs +1 -1
- package/src/tui/cmd-restore.mjs +2 -2
- package/src/tui/cmd-session.mjs +24 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +22 -9
- package/src/tui/config-helpers.mjs +1 -1
- package/src/tui/display-budget.mjs +33 -11
- package/src/tui/index.mjs +18 -10
- package/src/tui/interaction.mjs +16 -7
- package/src/tui/key-modes.mjs +9 -4
- package/src/tui/ledger-surface.mjs +22 -59
- package/src/tui/model-catalog.mjs +4 -4
- package/src/tui/model-picker.mjs +8 -7
- package/src/tui/mouse.mjs +11 -6
- package/src/tui/pickers.mjs +15 -2
- package/src/tui/render-conversation.mjs +1 -1
- package/src/tui/render-frame.mjs +15 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render-segments.mjs +3 -1
- package/src/tui/slash-commands.mjs +1 -1
- package/src/tui/startup.mjs +14 -14
- package/src/tui/subagent-blocks.mjs +20 -3
- package/src/tui/subagent-freeze.mjs +73 -2
- package/src/tui/suspension-drive.mjs +57 -23
- package/src/tui/tool-events.mjs +11 -8
- package/src/tui/tui-lifecycle.mjs +9 -0
- package/src/tui/wizard.mjs +3 -3
- package/src/tui/wrapped-spawn.mjs +6 -2
- package/src/abort-provenance.mjs +0 -116
- package/src/advisor/citations.mjs +0 -139
- package/src/advisor/compaction.mjs +0 -174
- package/src/advisor/convergence.mjs +0 -80
- package/src/advisor/history.mjs +0 -77
- package/src/advisor/loop.mjs +0 -293
- package/src/advisor/messages.mjs +0 -299
- package/src/advisor/project-context.mjs +0 -194
- package/src/advisor/repos.mjs +0 -150
- package/src/advisor/run.mjs +0 -293
- package/src/advisor/truncate.mjs +0 -57
- package/src/advisor.mjs +0 -290
- package/src/agent/completion.mjs +0 -146
- package/src/agent/dispatch.mjs +0 -489
- package/src/agent/helpers.mjs +0 -384
- package/src/agent/post-turn.mjs +0 -70
- package/src/agent/record-results.mjs +0 -174
- package/src/agent/relay-prefix.mjs +0 -39
- package/src/agent/run-stages.mjs +0 -244
- package/src/agent/setup-reminders.mjs +0 -69
- package/src/agent/setup.mjs +0 -354
- package/src/agent/spawn-child.mjs +0 -243
- package/src/agent-tools/advisor-async.mjs +0 -346
- package/src/agent-tools/advisor-settle.mjs +0 -231
- package/src/agent-tools/advisor.mjs +0 -260
- package/src/agent-tools/async-settle.mjs +0 -204
- package/src/agent-tools/batch-segment.mjs +0 -195
- package/src/agent-tools/consult.mjs +0 -473
- package/src/agent-tools/design-token.mjs +0 -117
- package/src/agent-tools/digest-budget.mjs +0 -76
- package/src/agent-tools/eng.mjs +0 -67
- package/src/agent-tools/escalate-async.mjs +0 -295
- package/src/agent-tools/goal.mjs +0 -119
- package/src/agent-tools/plan.mjs +0 -81
- package/src/agent-tools/read-history.mjs +0 -309
- package/src/agent-tools/recent-changes.mjs +0 -24
- package/src/agent-tools/review-streak.mjs +0 -93
- package/src/agent-tools/settings.mjs +0 -265
- package/src/agent-tools/skill.mjs +0 -47
- package/src/agent-tools/subagent-actions.mjs +0 -482
- package/src/agent-tools/subagent-async.mjs +0 -434
- package/src/agent-tools/subagent-panel.mjs +0 -160
- package/src/agent-tools/subagent-run.mjs +0 -205
- package/src/agent-tools/subagent-scheduler.mjs +0 -392
- package/src/agent-tools/subagent-spawn.mjs +0 -459
- package/src/agent-tools/subagent.mjs +0 -404
- package/src/agent-tools/task.mjs +0 -87
- package/src/agent-tools/timer.mjs +0 -46
- package/src/agent-tools/verify.mjs +0 -271
- package/src/agent-tools.mjs +0 -17
- package/src/agent.mjs +0 -417
- package/src/auto-think.mjs +0 -115
- package/src/config-migrate.mjs +0 -70
- package/src/config.mjs +0 -496
- package/src/context.mjs +0 -392
- package/src/conventions.mjs +0 -223
- package/src/embedding.mjs +0 -120
- package/src/escape.mjs +0 -152
- package/src/expand-home.mjs +0 -16
- package/src/explore-distill.mjs +0 -155
- package/src/generate-title.mjs +0 -88
- package/src/git/checkpoint.mjs +0 -448
- package/src/git/gitmem.mjs +0 -100
- package/src/hooks.mjs +0 -97
- package/src/ledger.mjs +0 -227
- package/src/log.mjs +0 -195
- package/src/markdown.mjs +0 -106
- package/src/mcp/helpers.mjs +0 -51
- package/src/mcp/transport-http.mjs +0 -248
- package/src/mcp/transport-stdio.mjs +0 -140
- package/src/mcp/transport-ws.mjs +0 -122
- package/src/mcp.mjs +0 -295
- package/src/memory/code-index.mjs +0 -219
- package/src/memory/code-sync.mjs +0 -415
- package/src/memory/core.mjs +0 -299
- package/src/memory/delete.mjs +0 -236
- package/src/memory/docs.mjs +0 -419
- package/src/memory/file-walk.mjs +0 -109
- package/src/memory/scan.mjs +0 -95
- package/src/memory/schema.mjs +0 -452
- package/src/memory.mjs +0 -21
- package/src/model-ref.mjs +0 -66
- package/src/model-specs.mjs +0 -179
- package/src/peer-domains.mjs +0 -265
- package/src/peer-instances.mjs +0 -231
- package/src/prompt-overlays.mjs +0 -82
- package/src/prompts/advisor-design.md +0 -41
- package/src/prompts/advisor-round1.md +0 -41
- package/src/prompts/advisor-round2.md +0 -46
- package/src/prompts/advisor-round3.md +0 -42
- package/src/prompts/common.md +0 -115
- package/src/prompts/consult-base.md +0 -19
- package/src/prompts/discipline-engineering.md +0 -258
- package/src/prompts/discipline-normal.md +0 -185
- package/src/prompts/persona-coder.md +0 -21
- package/src/prompts/persona-eng-coder.md +0 -37
- package/src/prompts/persona-eng-designer.md +0 -60
- package/src/prompts/persona-engineering.md +0 -55
- package/src/prompts/persona-explore.md +0 -15
- package/src/prompts/persona-normal.md +0 -27
- package/src/prompts/persona-plan.md +0 -26
- package/src/provider/anthropic.mjs +0 -225
- package/src/provider/core.mjs +0 -476
- package/src/provider/errors.mjs +0 -101
- package/src/provider/google.mjs +0 -257
- package/src/provider/index.mjs +0 -7
- package/src/provider/list-models.mjs +0 -93
- package/src/provider/normalize.mjs +0 -81
- package/src/provider/rate.mjs +0 -108
- package/src/provider/responses.mjs +0 -495
- package/src/provider/retry.mjs +0 -88
- package/src/provider/sse.mjs +0 -264
- package/src/proxy.mjs +0 -261
- package/src/rules.mjs +0 -53
- package/src/session-gc.mjs +0 -221
- package/src/session-guard.mjs +0 -59
- package/src/session-migrate.mjs +0 -48
- package/src/session-rename.mjs +0 -38
- package/src/session-segments.mjs +0 -100
- package/src/session-slots.mjs +0 -492
- package/src/session-store.mjs +0 -441
- package/src/session.mjs +0 -492
- package/src/skills.mjs +0 -153
- package/src/text-budget.mjs +0 -46
- package/src/token-ttl.mjs +0 -274
- package/src/tools/apply_patch.md +0 -15
- package/src/tools/bash.md +0 -37
- package/src/tools/bash.mjs +0 -268
- package/src/tools/checklist-sync.mjs +0 -181
- package/src/tools/checklist.md +0 -13
- package/src/tools/checklist.mjs +0 -299
- package/src/tools/delete.md +0 -13
- package/src/tools/edit-batch.mjs +0 -191
- package/src/tools/edit-diff.mjs +0 -348
- package/src/tools/edit.md +0 -30
- package/src/tools/execute.md +0 -21
- package/src/tools/execute.mjs +0 -228
- package/src/tools/fetch.md +0 -12
- package/src/tools/file.mjs +0 -469
- package/src/tools/file_ops.md +0 -17
- package/src/tools/get_current_time.md +0 -8
- package/src/tools/git-checkpoint.mjs +0 -143
- package/src/tools/git-ext.mjs +0 -173
- package/src/tools/git.md +0 -54
- package/src/tools/git.mjs +0 -356
- package/src/tools/glob-dialect.mjs +0 -130
- package/src/tools/glob.md +0 -11
- package/src/tools/grep.md +0 -19
- package/src/tools/hashline_edit.md +0 -14
- package/src/tools/index.mjs +0 -36
- package/src/tools/insert_after.md +0 -15
- package/src/tools/lint.md +0 -10
- package/src/tools/linter.mjs +0 -128
- package/src/tools/ls.md +0 -12
- package/src/tools/lsp.md +0 -10
- package/src/tools/lsp.mjs +0 -316
- package/src/tools/ops.mjs +0 -299
- package/src/tools/patch.mjs +0 -282
- package/src/tools/process.md +0 -10
- package/src/tools/question.md +0 -16
- package/src/tools/question.mjs +0 -26
- package/src/tools/read.md +0 -20
- package/src/tools/read_image.md +0 -8
- package/src/tools/repomap.mjs +0 -314
- package/src/tools/search.mjs +0 -236
- package/src/tools/shared.mjs +0 -446
- package/src/tools/tree.md +0 -14
- package/src/tools/tree.mjs +0 -66
- package/src/tools/wait_for.md +0 -22
- package/src/tools/web.mjs +0 -224
- package/src/tools/websearch.md +0 -16
- package/src/tools/write.md +0 -11
- package/src/traces/trace-store.mjs +0 -355
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-caps.mjs — `initialize` / `authenticate` handlers + the server-level
|
|
3
|
+
* client-capability snapshot (ACP-CLIENT.md §3.4 · §11.2 · §11.3).
|
|
4
|
+
*
|
|
5
|
+
* `initialize` is the protocol's ONLY capability-exchange point (schema:
|
|
6
|
+
* `InitializeRequest.clientCapabilities`). The snapshot written here is read once
|
|
7
|
+
* — by the auth-methods gate (this module) and the fs reverse-RPC gate
|
|
8
|
+
* (`bridge.mjs`) — and never mutated again (protocol semantics). Omitted
|
|
9
|
+
* capabilities are UNSUPPORTED (文档逐字「MUST treat all capabilities omitted in
|
|
10
|
+
* the initialize request as UNSUPPORTED」) — never optimistic defaults.
|
|
11
|
+
*/
|
|
12
|
+
import { ACP_ERRORS } from "./transport.mjs"
|
|
13
|
+
|
|
14
|
+
/** Protocol versions this agent speaks (schema `ProtocolVersion`; v1 — §11.3 改法①). */
|
|
15
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = [1]
|
|
16
|
+
|
|
17
|
+
/** Version negotiation (文档逐字): same version when supported, else the latest we support. */
|
|
18
|
+
export function negotiateProtocolVersion(requested, supported = SUPPORTED_PROTOCOL_VERSIONS) {
|
|
19
|
+
const latest = Math.max(...supported)
|
|
20
|
+
return Number.isInteger(requested) && supported.includes(requested) ? requested : latest
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `initialize.authMethods` entries (schema `AuthMethod` objects — `AuthMethodTerminal`
|
|
24
|
+
* requires `id` + `name`). The `terminal` entry MUST be advertised **only when the client
|
|
25
|
+
* enabled its terminal auth capability** (`clientCaps.auth.terminal === true`); otherwise
|
|
26
|
+
* `[]`. `args` are APPENDED to the configured agent invocation (the descriptor cannot
|
|
27
|
+
* provide a command) ⇒ the client actually launches `thincoder acp --login`. */
|
|
28
|
+
export function authMethodsFor(clientCaps) {
|
|
29
|
+
if (clientCaps?.auth?.terminal !== true) return []
|
|
30
|
+
return [{
|
|
31
|
+
id: "terminal",
|
|
32
|
+
type: "terminal",
|
|
33
|
+
name: "Terminal login",
|
|
34
|
+
description: "Run `thincoder acp --login` in an interactive terminal to configure the provider API key.",
|
|
35
|
+
args: ["--login"],
|
|
36
|
+
}]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the initialize / authenticate handlers.
|
|
41
|
+
* @param {{ version: string, clientCaps: { current: object },
|
|
42
|
+
* requireConfigured: () => { error?: object }, log?: (s: string) => void }} deps
|
|
43
|
+
* `requireConfigured` = the shared credential gate (§11.5 — lives in handlers-session.mjs).
|
|
44
|
+
* @returns {Record<string, (params: object) => object>}
|
|
45
|
+
*/
|
|
46
|
+
export function createCapsHandlers({ version, clientCaps, requireConfigured, log = () => {} }) {
|
|
47
|
+
return {
|
|
48
|
+
initialize: (params) => {
|
|
49
|
+
const caps = params?.clientCapabilities && typeof params.clientCapabilities === "object" ? params.clientCapabilities : {}
|
|
50
|
+
clientCaps.current = caps // §3.4 — written once (initialize is the only exchange point)
|
|
51
|
+
log(`[acp] initialize — protocolVersion=${JSON.stringify(params?.protocolVersion)} auth.terminal=${caps.auth?.terminal === true}`)
|
|
52
|
+
return {
|
|
53
|
+
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
54
|
+
// 能力面如实声明(§11.3 改法③):已实现的 loadSession + 四键 sessionCapabilities;
|
|
55
|
+
// promptCapabilities 全 false(本面不接 image/audio/embeddedContext——不虚报);
|
|
56
|
+
// mcpCapabilities / additionalDirectories 省略 = 不支持(G7 / G4 本批不做)。
|
|
57
|
+
agentCapabilities: {
|
|
58
|
+
loadSession: true,
|
|
59
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
|
60
|
+
sessionCapabilities: { list: {}, resume: {}, delete: {}, close: {} },
|
|
61
|
+
},
|
|
62
|
+
authMethods: authMethodsFor(caps),
|
|
63
|
+
agentInfo: { name: "thincoder", version },
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
authenticate: (params) => {
|
|
68
|
+
// 契约形状(§11.2 改法③):methodId 不属于**当前已宣告**的方法 → -32602;
|
|
69
|
+
// 凭据不可解析 → -32000;通过 → 空结果 `{}`(AuthenticateResponse 只有 `_meta`)。
|
|
70
|
+
// 注:契约禁止客户端以 `terminal` 调本方法——若客户端仍调且该法已宣告,本实现宽容放行。
|
|
71
|
+
const advertised = authMethodsFor(clientCaps.current)
|
|
72
|
+
const methodId = params?.methodId
|
|
73
|
+
if (!advertised.some((m) => m.id === methodId)) {
|
|
74
|
+
return {
|
|
75
|
+
error: {
|
|
76
|
+
...ACP_ERRORS.INVALID_PARAMS,
|
|
77
|
+
message: `unknown auth method: ${String(methodId)} (advertised: ${advertised.map((m) => m.id).join(", ") || "none"})`,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const gate = requireConfigured()
|
|
82
|
+
if (gate.error) return gate
|
|
83
|
+
return {}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/acp/ext.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ext.mjs — ACP extension pull face (ACP-CLIENT.md §3.5): `checkpoint/*` + `memory/*`.
|
|
3
|
+
* **纯搬迁** from the former monolithic `acp.mjs` (cited block = 68 lines) + factory shape;
|
|
4
|
+
* zero semantics changed — only the credential gate is now the shared immediate predicate
|
|
5
|
+
* (§11.2 改法①) and `getCwd` / `log` come through `ctx`.
|
|
6
|
+
*
|
|
7
|
+
* Checkpoints are cwd-scoped (same store the TUI git tool uses); non-git cwds
|
|
8
|
+
* snapshot by full-directory copy instead of returning null.
|
|
9
|
+
* Memory reuses the same `~/.thincoder/memory.db` store the TUI does.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import { configDir } from "@thincoder/core/config.mjs"
|
|
13
|
+
import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "@thincoder/core/git/checkpoint.mjs"
|
|
14
|
+
import { createMemory, list as memList, remove as memRemove } from "@thincoder/core/memory.mjs"
|
|
15
|
+
import { ACP_ERRORS } from "./transport.mjs"
|
|
16
|
+
|
|
17
|
+
/** Build the extension handlers. Shared state arrives via the single `ctx` (§3.5). */
|
|
18
|
+
export function createExtHandlers(ctx) {
|
|
19
|
+
const { getCwd, requireConfigured } = ctx
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
"checkpoint/create": async () => {
|
|
23
|
+
const gate = requireConfigured()
|
|
24
|
+
if (gate.error) return gate
|
|
25
|
+
const cp = await createCheckpoint(getCwd())
|
|
26
|
+
if (!cp) return { error: { ...ACP_ERRORS.INTERNAL, message: "checkpoint creation failed" } }
|
|
27
|
+
return { checkpoint: { id: cp.id, time: cp.time, files: cp.files, git: isGitRepo(getCwd()) } }
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
"checkpoint/list": async () => {
|
|
31
|
+
const gate = requireConfigured()
|
|
32
|
+
if (gate.error) return gate
|
|
33
|
+
const cps = await listCheckpoints(getCwd())
|
|
34
|
+
return { checkpoints: cps.map((c) => ({ id: c.id, time: c.time, files: (c.tracked?.length ?? 0) + (c.untracked?.length ?? 0), trackedCount: c.tracked?.length ?? 0, untrackedCount: c.untracked?.length ?? 0 })) }
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
"checkpoint/restore": async (params) => {
|
|
38
|
+
const gate = requireConfigured()
|
|
39
|
+
if (gate.error) return gate
|
|
40
|
+
if (!params.checkpointId || !params.path) {
|
|
41
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "checkpoint/restore requires checkpointId and path (single-file restore; full rewind is disabled)" } }
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await rewind(getCwd(), String(params.checkpointId), { path: String(params.path) })
|
|
45
|
+
return { restored: String(params.path) }
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: e?.message ?? String(e) } }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
"memory/list": async (params) => {
|
|
52
|
+
const gate = requireConfigured()
|
|
53
|
+
if (gate.error) return gate
|
|
54
|
+
const mem = ensureAcpMemory()
|
|
55
|
+
if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
|
|
56
|
+
const entries = await memList(mem, { type: params.type })
|
|
57
|
+
return { entries: entries.map((e) => ({ id: e.id, type: e.type, title: e.title, tags: e.tags ?? "", updatedAt: e.updatedAt ?? null })) }
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
"memory/remove": async (params) => {
|
|
61
|
+
const gate = requireConfigured()
|
|
62
|
+
if (gate.error) return gate
|
|
63
|
+
const id = Number(params.id)
|
|
64
|
+
if (!Number.isInteger(id) || id < 1) {
|
|
65
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `memory/remove requires a numeric id (got ${params.id})` } }
|
|
66
|
+
}
|
|
67
|
+
const mem = ensureAcpMemory()
|
|
68
|
+
if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
|
|
69
|
+
const ok = await memRemove(mem, id)
|
|
70
|
+
return ok ? { removed: id } : { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `no memory entry #${id}` } }
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shared memory handle for ACP handlers (same ~/.thincoder store the TUI uses).
|
|
76
|
+
* dbPath mirrors the TUI default (see cli/make-agent.mjs). */
|
|
77
|
+
let _acpMemory = null
|
|
78
|
+
function ensureAcpMemory() {
|
|
79
|
+
if (_acpMemory) return _acpMemory
|
|
80
|
+
try {
|
|
81
|
+
_acpMemory = createMemory({ dbPath: join(configDir, "memory.db") })
|
|
82
|
+
return _acpMemory
|
|
83
|
+
} catch {
|
|
84
|
+
return null // memory subsystem unavailable — handlers report it
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handlers-session.mjs — in-memory ACP session family (ACP-CLIENT.md §3.5):
|
|
3
|
+
* `session/{new,prompt,cancel,close,set_mode,set_config_option}` + `findSession` /
|
|
4
|
+
* `applyConfigOption` + the shared credential gate consumed by every gated handler
|
|
5
|
+
* across the four handler modules (§11.5).
|
|
6
|
+
*
|
|
7
|
+
* 认证门 = **凭据状态即时判据**(§11.2 改法① / D15——撤 `authenticated` 闩锁):
|
|
8
|
+
* 受门 handler 首行求值,真 ⇒ 放行、假 ⇒ `-32000`。客户端可以不调 `authenticate`
|
|
9
|
+
* (契约逐字「**May** return an `auth_required` error … **if** the agent requires
|
|
10
|
+
* authentication」)。
|
|
11
|
+
*/
|
|
12
|
+
import { resolve } from "node:path"
|
|
13
|
+
import { normalizeCwd, bindRecordStore, newSession, slotPath } from "@thincoder/core/session.mjs"
|
|
14
|
+
import { PLAN_ENGINEERING_REFUSED } from "@thincoder/core/agent-tools/plan.mjs"
|
|
15
|
+
import { ACP_ERRORS } from "./transport.mjs"
|
|
16
|
+
|
|
17
|
+
/** ENG-PLAN-EXCLUSION(FR31 ② · E7「命令面逐面钉定」):工程真值 = 核单源同键
|
|
18
|
+
* `agent.config.agent.engineering`。 */
|
|
19
|
+
const isEngineering = (agent) => agent?.config?.agent?.engineering === true
|
|
20
|
+
|
|
21
|
+
/** Session-level config options exposed to clients — schema `SessionConfigOption.required = ["id","name"]`
|
|
22
|
+
* (响应侧用 `id`;请求侧 `SetSessionConfigOptionRequest` 用 `configId`——不对称属契约本身 · §11.3 G2-8). */
|
|
23
|
+
export const CONFIG_OPTIONS = [
|
|
24
|
+
{ id: "model", name: "Model" },
|
|
25
|
+
{ id: "thinking", name: "Thinking" },
|
|
26
|
+
{ id: "mode", name: "Mode" },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Credential gate (§11.5 判据拆分 · 评审 #1). Evaluated per call — **no cross-call latch**.
|
|
31
|
+
* `isConfigured()` (boolean, same injection point as before) decides pass/deny; the sister
|
|
32
|
+
* probe `providerStatus()` → `{ ok, keyPresent, reason }` only picks the actionable message
|
|
33
|
+
* for a deny (it distinguishes "no key" from "key present but defaultModel unresolvable"):
|
|
34
|
+
* ① `keyPresent === false` ⇒ point at providers[].apiKey + `thincoder acp --login` (no reason glued in);
|
|
35
|
+
* ② `keyPresent && !ok` ⇒ name the reason verbatim — `defaultModelReason` 逐字含 "defaultModel"
|
|
36
|
+
* (§11.5: ② 必点名 `defaultModel`) + the same recovery paths.
|
|
37
|
+
* @param {{ isConfigured: () => boolean, providerStatus: () => { ok?: boolean, keyPresent?: boolean, reason?: string } }} deps
|
|
38
|
+
* @returns {() => { error?: object }} `{}` ⇒ the call may proceed.
|
|
39
|
+
*/
|
|
40
|
+
export function createRequireConfigured({ isConfigured, providerStatus }) {
|
|
41
|
+
return () => {
|
|
42
|
+
if (isConfigured()) return {}
|
|
43
|
+
let st = {}
|
|
44
|
+
try { st = providerStatus() ?? {} } catch { st = {} }
|
|
45
|
+
if (st.keyPresent !== true) {
|
|
46
|
+
return {
|
|
47
|
+
error: {
|
|
48
|
+
...ACP_ERRORS.AUTH_REQUIRED,
|
|
49
|
+
message: `no provider API key configured — set providers[].apiKey in ~/.thincoder/config.json, or run "thincoder acp --login"`,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
error: {
|
|
55
|
+
...ACP_ERRORS.AUTH_REQUIRED,
|
|
56
|
+
message: `credentials present but the session cannot be assembled: ${st.reason ?? "unknown provider reason"} (fix via /config → 默认模型, or run "thincoder acp --login")`,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply a session-level config option to the agent instance (memory only —
|
|
64
|
+
* the session's own runtime state, last-write-wins; not persisted to config.json).
|
|
65
|
+
* Returns true when the configId is known.
|
|
66
|
+
*/
|
|
67
|
+
export function applyConfigOption(agent, configId, value) {
|
|
68
|
+
switch (configId) {
|
|
69
|
+
case "model": {
|
|
70
|
+
if (typeof value !== "string" || !value.trim()) return false
|
|
71
|
+
if (!agent.provider) return false // nothing to configure
|
|
72
|
+
// Split on the FIRST colon only — model names may contain colons.
|
|
73
|
+
const ci = value.indexOf(":")
|
|
74
|
+
const provider = ci >= 0 ? value.slice(0, ci) : null
|
|
75
|
+
const model = (ci >= 0 ? value.slice(ci + 1) : value).trim()
|
|
76
|
+
if (provider && provider !== agent.provider.name) agent.provider.name = provider
|
|
77
|
+
agent.provider.model = model
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
case "thinking": {
|
|
81
|
+
if (typeof value !== "boolean" || !agent.provider) return false
|
|
82
|
+
agent.provider.thinking = value ? { type: "enabled" } : { type: "disabled" }
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
case "mode": {
|
|
86
|
+
if (value !== "plan" && value !== "normal") return false
|
|
87
|
+
agent.planMode = value === "plan"
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the in-memory session family. Shared state arrives via the single `ctx`
|
|
97
|
+
* (§3.5 — `{ getCwd, sessions, allocSessionId, notifyRef, requestRef, createSession,
|
|
98
|
+
* requireConfigured, log }`), so the id allocator and the slot family stay on one
|
|
99
|
+
* namespace.
|
|
100
|
+
*/
|
|
101
|
+
export function createSessionHandlers(ctx) {
|
|
102
|
+
const { getCwd, sessions, allocSessionId, notifyRef, requestRef, createSession, requireConfigured, log } = ctx
|
|
103
|
+
|
|
104
|
+
const findSession = (params) => {
|
|
105
|
+
const s = sessions.get(String(params?.sessionId))
|
|
106
|
+
if (!s) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown session ${params?.sessionId}` } }
|
|
107
|
+
return { session: s }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"session/new": async (params) => {
|
|
112
|
+
const gate = requireConfigured()
|
|
113
|
+
if (gate.error) return gate
|
|
114
|
+
if (params.cwd !== undefined && typeof params.cwd !== "string") {
|
|
115
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "cwd must be a string" } }
|
|
116
|
+
}
|
|
117
|
+
// Normalized comparison: resolve() collapses trailing slashes, and
|
|
118
|
+
// normalizeCwd().toLowerCase() makes the check case-insensitive on
|
|
119
|
+
// Windows (drive letter + path — a client sending "c:\\users\\…" vs
|
|
120
|
+
// process.cwd() "C:\Users\…" must match). The ternary guards against
|
|
121
|
+
// resolve(undefined) — it would coerce undefined to the literal
|
|
122
|
+
// "undefined" and resolve a nonsense path. Note: `requested` never
|
|
123
|
+
// feeds any path operation — the agent always runs in getCwd() — so
|
|
124
|
+
// a case-insensitive match on case-sensitive platforms is harmless.
|
|
125
|
+
const norm = (p) => normalizeCwd(p).toLowerCase()
|
|
126
|
+
const requested = params.cwd ? resolve(params.cwd) : getCwd()
|
|
127
|
+
if (norm(requested) !== norm(getCwd())) {
|
|
128
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `v1: cwd must equal the process working directory (${getCwd()})` } }
|
|
129
|
+
}
|
|
130
|
+
if (params.mcpServers?.length) {
|
|
131
|
+
log(`[acp] MCP forwarding is M2 scope — ignoring ${params.mcpServers.length} server(s)`)
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const id = allocSessionId()
|
|
135
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
136
|
+
// §11.5 装配后检查(兜底,与门互补):门拦「key 缺失 / defaultModel 不可解析」;
|
|
137
|
+
// 本行拦「门放行但 provider 仍不可用」的残余(如 defaultModel 可解析而 provider 缺
|
|
138
|
+
// baseURL)——显式报错而**不建半死会话**(槽未认领、sessions 未登记——无半态)。
|
|
139
|
+
if (session?.agent?._providerInvalid) {
|
|
140
|
+
return {
|
|
141
|
+
error: {
|
|
142
|
+
...ACP_ERRORS.AUTH_REQUIRED,
|
|
143
|
+
message: `session cannot be assembled: ${session.agent._providerInvalidReason ?? "provider unavailable"} (fix via /config → 默认模型, or run "thincoder acp --login")`,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// `id` is immutable after construction (baked into the callbacks) — never reassign.
|
|
148
|
+
// 2026-09-01 会诊 kimi/glm 🔴:立即认领独立槽(对齐 cmd-new)——否则首回合保存
|
|
149
|
+
// 走 _slot ??= activeSlot() → ensureActive 早退分支(slotSessions[active]===
|
|
150
|
+
// mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
|
|
151
|
+
// F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
|
|
152
|
+
session.agent._slot = await newSession(getCwd())
|
|
153
|
+
// TUI-OOM-ROOTCAUSE(SESSION.md §6.14):新建槽绑定记录存储(baseHistory 空——
|
|
154
|
+
// identity 待固化)——与 /new 同语义
|
|
155
|
+
bindRecordStore(session.agent, { slotFile: slotPath(getCwd(), session.agent._slot), identity: session.agent._sessionStart ?? null, baseHistory: [] })
|
|
156
|
+
sessions.set(id, session)
|
|
157
|
+
// §11.3 G2-5:NewSessionResponse.required = ["sessionId"](`id` / `configId` 键已收正)
|
|
158
|
+
return { sessionId: id, configOptions: [...CONFIG_OPTIONS] }
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to create session: ${e.message}` } }
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
"session/prompt": async (params) => {
|
|
165
|
+
const gate = requireConfigured()
|
|
166
|
+
if (gate.error) return gate
|
|
167
|
+
const found = findSession(params)
|
|
168
|
+
if (found.error) return found
|
|
169
|
+
// §11.3 G2-6:PromptRequest.required = ["sessionId","prompt"](`prompt` = ContentBlock[])——
|
|
170
|
+
// 读取位置由 `params.content` 收正为 `params.prompt`;取块策略不变(首个 text 块——G6 登记项)。
|
|
171
|
+
const blocks = Array.isArray(params?.prompt) ? params.prompt : []
|
|
172
|
+
const text = blocks.find((b) => b?.type === "text")?.text ?? ""
|
|
173
|
+
if (!text) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "prompt requires a text content block" } }
|
|
174
|
+
try {
|
|
175
|
+
await found.session.run(text)
|
|
176
|
+
return { stopReason: "end_turn" }
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// Cancelled/interrupted turns are not errors on the wire.
|
|
179
|
+
if (e?.name === "AbortError" || e?.code === "ABORT_ERR") return { stopReason: "cancelled" }
|
|
180
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: e?.message ?? String(e) } }
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
"session/cancel": (params) => {
|
|
185
|
+
const gate = requireConfigured()
|
|
186
|
+
if (gate.error) return gate
|
|
187
|
+
const found = findSession(params)
|
|
188
|
+
if (found.error) return found
|
|
189
|
+
found.session.cancel()
|
|
190
|
+
return {}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
"session/close": (params) => {
|
|
194
|
+
const gate = requireConfigured()
|
|
195
|
+
if (gate.error) return gate
|
|
196
|
+
// 未知会话静默返回 `{}`(既有语义,本批零改——会话族其余方法返 `unknown session`)。
|
|
197
|
+
const found = findSession(params)
|
|
198
|
+
if (!found.error) {
|
|
199
|
+
// Abort any in-flight turn first — the client is gone, the agent must
|
|
200
|
+
// stop consuming LLM tokens and emitting notifications.
|
|
201
|
+
found.session.cancel()
|
|
202
|
+
sessions.delete(String(params.sessionId))
|
|
203
|
+
log(`session ${params.sessionId} closed by client`)
|
|
204
|
+
}
|
|
205
|
+
return {}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
"session/set_config_option": (params) => {
|
|
209
|
+
const gate = requireConfigured()
|
|
210
|
+
if (gate.error) return gate
|
|
211
|
+
const found = findSession(params)
|
|
212
|
+
if (found.error) return found
|
|
213
|
+
const { configId, value } = params
|
|
214
|
+
if (!configId || value === undefined) {
|
|
215
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "set_config_option requires configId and value" } }
|
|
216
|
+
}
|
|
217
|
+
// ENG-PLAN-EXCLUSION(FR31 ② / 评审轮 1 发现 #4 出口钉定):工程模式 ⇒ plan 面**前置判**
|
|
218
|
+
// ⇒ 携共用文案的 INVALID_PARAMS。`applyConfigOption` 布尔契约零改(`false` 仍 = unknown
|
|
219
|
+
// configId——工程拒绝不经该路径,下方 `:unknown configId` 误导文案对 mode=plan 不再可达);
|
|
220
|
+
// `mode:"normal"` 照常接受(唯一合法态,幂等)。
|
|
221
|
+
if (configId === "mode" && value === "plan" && isEngineering(found.session.agent)) {
|
|
222
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: PLAN_ENGINEERING_REFUSED } }
|
|
223
|
+
}
|
|
224
|
+
const applied = applyConfigOption(found.session.agent, configId, value)
|
|
225
|
+
if (!applied) {
|
|
226
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown configId: ${configId}` } }
|
|
227
|
+
}
|
|
228
|
+
// Last-write-wins on the internal state; notify the client of the change.
|
|
229
|
+
notifyRef.current("session/update", {
|
|
230
|
+
sessionId: String(params.sessionId),
|
|
231
|
+
update: { sessionUpdate: "config_option_update", configId, value },
|
|
232
|
+
})
|
|
233
|
+
return {}
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
"session/set_mode": (params) => {
|
|
237
|
+
const gate = requireConfigured()
|
|
238
|
+
if (gate.error) return gate
|
|
239
|
+
const found = findSession(params)
|
|
240
|
+
if (found.error) return found
|
|
241
|
+
if (params.mode !== "plan" && params.mode !== "normal") {
|
|
242
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "mode must be plan or normal" } }
|
|
243
|
+
}
|
|
244
|
+
// ENG-PLAN-EXCLUSION(FR31 ② / 同 #4):工程模式 + `mode:"plan"` ⇒ 前置判 ⇒ 携共用文案
|
|
245
|
+
// 的 INVALID_PARAMS(状态零变、零通知);`normal` 照常(幂等)。
|
|
246
|
+
if (params.mode === "plan" && isEngineering(found.session.agent)) {
|
|
247
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: PLAN_ENGINEERING_REFUSED } }
|
|
248
|
+
}
|
|
249
|
+
found.session.agent.planMode = params.mode === "plan"
|
|
250
|
+
notifyRef.current("session/update", {
|
|
251
|
+
sessionId: String(params.sessionId),
|
|
252
|
+
update: { sessionUpdate: "current_mode_update", mode: params.mode },
|
|
253
|
+
})
|
|
254
|
+
return {}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handlers-slots.mjs — persisted-slot family (ACP-CLIENT.md §3.5 · §11.3):
|
|
3
|
+
* `session/{list,load,resume,delete}` + the #41 engineering-manifest predicate
|
|
4
|
+
* helpers (`slotEngineering` / `reattach`).
|
|
5
|
+
*
|
|
6
|
+
* id 命名空间边界(§11.3 · 评审 #7——本批只改字段名、不改语义):本族按**持久化槽位号**
|
|
7
|
+
* 解析 `params.sessionId`(`Number(params.sessionId)`);而 `session/prompt` / `cancel` /
|
|
8
|
+
* `close` / `set_*` 认的是 **ACP 会话 id**(入口分配器——与槽位号不同物,§6.1)⇒
|
|
9
|
+
* 把 `session/list` 的输出直喂 `session/prompt` 仍会 `unknown session`(原 G5,本批不做)。
|
|
10
|
+
*/
|
|
11
|
+
import { ACP_ERRORS } from "./transport.mjs"
|
|
12
|
+
import { CONFIG_OPTIONS } from "./handlers-session.mjs"
|
|
13
|
+
import { loadConfig } from "@thincoder/core/config.mjs"
|
|
14
|
+
import { resolveEngineeringManifest } from "@thincoder/core/manifest.mjs"
|
|
15
|
+
import { replayHistory } from "./bridge.mjs"
|
|
16
|
+
import { listSlots, applySession, deleteSlot, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession } from "@thincoder/core/session.mjs"
|
|
17
|
+
|
|
18
|
+
/** Build the persisted-slot family. Shared state arrives via the single `ctx` (§3.5). */
|
|
19
|
+
export function createSlotsHandlers(ctx) {
|
|
20
|
+
const { getCwd, sessions, allocSessionId, notifyRef, requestRef, createSession, requireConfigured, log } = ctx
|
|
21
|
+
|
|
22
|
+
/** #41 翻转族合值判据(MANIFEST.md §2.8 F3 触发条件口径 = §2.2 会话权威值):「槽带
|
|
23
|
+
* `engineering` 字段 ? 槽值 : config 回退」。本路装配期(`applySession` 之前)无会话
|
|
24
|
+
* agent ⇒ 回退源 = 盘上 config(与装配产物同源:`defaultCreateSession` → `assembleAgent`
|
|
25
|
+
* → `loadConfig`)。 */
|
|
26
|
+
const slotEngineering = (data) => data?.engineering !== undefined
|
|
27
|
+
? data.engineering === true
|
|
28
|
+
: (() => { try { return loadConfig().agent?.engineering === true } catch { return false } })()
|
|
29
|
+
|
|
30
|
+
/** #41 装载后一行重估(F3 准翻列 / F4):`applySession` 已按槽订正 config ⇒ 工程模式会话
|
|
31
|
+
* 就地判据(单源)+ 附着——缺档格 `writer:'main'` 建档;非工程模式零动作(零 manifest I/O)。
|
|
32
|
+
* 判据不通过 ⇒ 保持不附着(装载已发生——本行是「装载后订正」位,与入口门槛两分)+ 留痕。 */
|
|
33
|
+
const reattach = (ag) => {
|
|
34
|
+
if (ag?.config?.agent?.engineering !== true) return
|
|
35
|
+
const r = resolveEngineeringManifest(getCwd(), { writer: "main" })
|
|
36
|
+
if (r.ok) ag.manifest = r.manifest
|
|
37
|
+
else log(`[acp] engineering manifest not attached (${r.code}): ${r.message}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"session/list": () => {
|
|
42
|
+
const gate = requireConfigured()
|
|
43
|
+
if (gate.error) return gate
|
|
44
|
+
const slots = listSlots(getCwd())
|
|
45
|
+
return {
|
|
46
|
+
// §11.3 G2-7:SessionInfo.required = ["sessionId","cwd"](条目键 `id` 已收正)
|
|
47
|
+
sessions: slots.map((s) => ({
|
|
48
|
+
sessionId: String(s.slot),
|
|
49
|
+
cwd: getCwd(), // single-cwd model (design §4.5)
|
|
50
|
+
updatedAt: s.updatedAt ?? 0,
|
|
51
|
+
title: s.title ?? "",
|
|
52
|
+
messageCount: s.messageCount ?? 0,
|
|
53
|
+
})),
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
"session/load": async (params) => {
|
|
58
|
+
const gate = requireConfigured()
|
|
59
|
+
if (gate.error) return gate
|
|
60
|
+
const slot = Number(params.sessionId)
|
|
61
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
62
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
63
|
+
}
|
|
64
|
+
const data = loadSlotFile(getCwd(), slot)
|
|
65
|
+
if (!data) {
|
|
66
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
|
|
67
|
+
}
|
|
68
|
+
// 2026-08-31 会诊 deepseek 🟡(搬迁保留):读槽走 session.mjs 共享 `loadSlotFile`(校验/
|
|
69
|
+
// 保现场/.tmp 回退与主路径一致)——本地实现此前无 .unreadable/.corrupted 保留。
|
|
70
|
+
// #41 先判(F3 合值 / F4 装配期判据):目标槽合值 === true ⇒ 判据前置于 `createSession`
|
|
71
|
+
// (拒 ⇒ 走既有 ACP 错误通道、**不建会话**——无半态);准 ⇒ 既有装载 + 下方重估。
|
|
72
|
+
if (slotEngineering(data)) {
|
|
73
|
+
const r = resolveEngineeringManifest(getCwd(), { writer: "main" })
|
|
74
|
+
if (!r.ok) {
|
|
75
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${r.message}` } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const id = allocSessionId()
|
|
80
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
81
|
+
// 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
|
|
82
|
+
// IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
|
|
83
|
+
// 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
|
|
84
|
+
// 到新槽(与 switchToSlot 的"占用则 fork"语义对齐)。
|
|
85
|
+
const occ = slotOccupancy(getCwd(), slot)
|
|
86
|
+
// 2026-09-01 会诊 kimi/glm 🔴:同进程双会话同槽——slotOccupancy 排除本进程属主后,
|
|
87
|
+
// 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
|
|
88
|
+
// (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
|
|
89
|
+
const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
|
|
90
|
+
const pinned = !occ.occupied && !sameProcessPinned
|
|
91
|
+
// TUI-OOM-ROOTCAUSE(SESSION.md §6.14):钉槽(非占用)才绑定记录存储——
|
|
92
|
+
// 占用/fork 分支模式 F(首保存 fork 新槽后补绑)
|
|
93
|
+
applySession(session.agent, data, pinned ? { slot } : {})
|
|
94
|
+
reattach(session.agent)
|
|
95
|
+
if (pinned) {
|
|
96
|
+
const m = loadManifest(getCwd())
|
|
97
|
+
m.slotSessions ??= {}
|
|
98
|
+
m.slotSessions[slot] = getSessionId()
|
|
99
|
+
saveManifest(getCwd(), m)
|
|
100
|
+
session.agent._slot = slot
|
|
101
|
+
} else {
|
|
102
|
+
// 2026-09-01 advisor 🔴:占用时显式分配全新槽(fork)——原 `_slot = null` 的
|
|
103
|
+
// fork 会经 saveSession → activeSlot → ensureActive 分支1 早退(slotSessions
|
|
104
|
+
// [active] === mySessionId 同进程恒真)落回同进程 active 槽 → 两会话写同一槽
|
|
105
|
+
// 静默互覆盖。newSession 跳过活认领号/现存文件号,必定落到新槽。
|
|
106
|
+
session.agent._slot = await newSession(getCwd())
|
|
107
|
+
}
|
|
108
|
+
sessions.set(id, session)
|
|
109
|
+
// Replay the human line (role → chunk mapping, design §4.5) so the
|
|
110
|
+
// client renders the restored conversation.
|
|
111
|
+
replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
|
|
112
|
+
log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
|
|
113
|
+
// §11.3:load/resume 响应 required = [](`id` / `cwd` 属多余键——同批收敛)
|
|
114
|
+
return { configOptions: [...CONFIG_OPTIONS] }
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
"session/resume": async (params) => {
|
|
121
|
+
const gate = requireConfigured()
|
|
122
|
+
if (gate.error) return gate
|
|
123
|
+
const slot = Number(params.sessionId)
|
|
124
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
125
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
126
|
+
}
|
|
127
|
+
const data = loadSlotFile(getCwd(), slot)
|
|
128
|
+
if (!data) {
|
|
129
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
|
|
130
|
+
}
|
|
131
|
+
// #41 先判(同 session/load——合值判据前置于 `createSession`;拒 ⇒ 不建会话)。
|
|
132
|
+
if (slotEngineering(data)) {
|
|
133
|
+
const r = resolveEngineeringManifest(getCwd(), { writer: "main" })
|
|
134
|
+
if (!r.ok) {
|
|
135
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${r.message}` } }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const id = allocSessionId()
|
|
140
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
141
|
+
// 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
|
|
142
|
+
// 下次保存 fork 新槽);空闲则认领后钉回。
|
|
143
|
+
const occ = slotOccupancy(getCwd(), slot)
|
|
144
|
+
// 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
|
|
145
|
+
const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
|
|
146
|
+
const pinned = !occ.occupied && !sameProcessPinned
|
|
147
|
+
// TUI-OOM-ROOTCAUSE(SESSION.md §6.14):同 load——钉槽才绑定(占用/fork 模式 F)
|
|
148
|
+
applySession(session.agent, data, pinned ? { slot } : {})
|
|
149
|
+
reattach(session.agent)
|
|
150
|
+
if (pinned) {
|
|
151
|
+
const m = loadManifest(getCwd())
|
|
152
|
+
m.slotSessions ??= {}
|
|
153
|
+
m.slotSessions[slot] = getSessionId()
|
|
154
|
+
saveManifest(getCwd(), m)
|
|
155
|
+
session.agent._slot = slot
|
|
156
|
+
} else {
|
|
157
|
+
// 2026-09-01 advisor 🔴:同 load——显式 newSession fork(_slot=null 的 fork 会
|
|
158
|
+
// 落回同进程 active 槽 → 两会话写同一槽静默互覆盖)
|
|
159
|
+
session.agent._slot = await newSession(getCwd())
|
|
160
|
+
}
|
|
161
|
+
sessions.set(id, session)
|
|
162
|
+
// resume: no history replay — the client keeps its own rendering.
|
|
163
|
+
log(`session ${slot} resumed as session ${id} (no replay)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
|
|
164
|
+
return { configOptions: [...CONFIG_OPTIONS] }
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
"session/delete": async (params) => {
|
|
171
|
+
const gate = requireConfigured()
|
|
172
|
+
if (gate.error) return gate
|
|
173
|
+
const slot = Number(params.sessionId)
|
|
174
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
175
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
176
|
+
}
|
|
177
|
+
// Only the persisted archive is removed; an active in-memory session
|
|
178
|
+
// with the same id keeps running (design §4.5).
|
|
179
|
+
if (!deleteSlot(getCwd(), slot)) {
|
|
180
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
|
|
181
|
+
}
|
|
182
|
+
// 2026-08-31 会诊 deepseek 🟡:被删槽的在存会话 _slot 仍钉着 → 下次保存会重建
|
|
183
|
+
// 文件并重注册(删后复活)。清空其 _slot,下次保存重新认领新槽。
|
|
184
|
+
// 2026-09-01 advisor round2 🔴:不能清 _slot 等下次保存——saveSession 走
|
|
185
|
+
// activeSlot → ensureActive 分支1 早退(slotSessions[active] === mySessionId 同
|
|
186
|
+
// 进程恒真)→ 落回同进程 active 槽(另一在存会话的槽)→ 两会话写同一槽静默
|
|
187
|
+
// 互覆盖(sessionStart 均 null → F2 永不轮转)。与 load/resume 同型修复:
|
|
188
|
+
// 立即 newSession 钉全新槽(跳过活认领号/现存文件号)。
|
|
189
|
+
for (const s of sessions.values()) {
|
|
190
|
+
if (s.agent?._slot === slot) s.agent._slot = await newSession(getCwd())
|
|
191
|
+
}
|
|
192
|
+
log(`session ${slot} archive deleted`)
|
|
193
|
+
return {}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|