@scotthuang/agent-knock-knock 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +18 -8
- package/dist/src/agent-session-provider.d.ts +1 -1
- package/dist/src/claude-local-transcript-provider.d.ts +29 -1
- package/dist/src/claude-local-transcript-provider.js +313 -0
- package/dist/src/claude-local-transcript-provider.js.map +1 -1
- package/dist/src/claude-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/claude-terminal-agent-adapter.js +178 -0
- package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
- package/dist/src/cli.js +4745 -355
- package/dist/src/cli.js.map +1 -1
- package/dist/src/codex-local-session-provider.d.ts +2 -2
- package/dist/src/codex-local-session-provider.js +2 -2
- package/dist/src/codex-local-session-provider.js.map +1 -1
- package/dist/src/codex-store-adapter.d.ts +11 -3
- package/dist/src/codex-store-adapter.js +371 -4
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/codex-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/codex-terminal-agent-adapter.js +192 -0
- package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
- package/dist/src/managed-session.d.ts +163 -0
- package/dist/src/managed-session.js +873 -0
- package/dist/src/managed-session.js.map +1 -0
- package/dist/src/native-thread-lifecycle-policy.d.ts +57 -0
- package/dist/src/native-thread-lifecycle-policy.js +116 -0
- package/dist/src/native-thread-lifecycle-policy.js.map +1 -0
- package/dist/src/openclaw-plugin-helpers.d.ts +31 -6
- package/dist/src/openclaw-plugin-helpers.js +226 -9
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +238 -23
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/protocol.d.ts +6 -0
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/session-store.d.ts +57 -0
- package/dist/src/session-store.js +529 -0
- package/dist/src/session-store.js.map +1 -0
- package/dist/src/store.d.ts +2 -1
- package/dist/src/store.js +233 -21
- package/dist/src/store.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +153 -0
- package/dist/src/terminal-agent-adapter.js +12 -0
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +8 -0
- package/dist/src/terminal-agent-bridge.js +15 -0
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-control-provider.d.ts +2 -0
- package/dist/src/terminal-control-provider.js +1 -0
- package/dist/src/terminal-control-provider.js.map +1 -1
- package/docs/quickstart-tmux.md +12 -1
- package/openclaw.plugin.json +14 -2
- package/package.json +1 -1
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +23 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add capability-scoped native-thread lifecycle controls for starting or clearing context, listing verified same-workspace resume candidates, and resuming one exact Codex or Claude Code native thread in the existing tmux pane.
|
|
8
|
+
- Add the optional OpenClaw tools `agent_knock_knock_new_thread`, `agent_knock_knock_list_resumable_threads`, and `agent_knock_knock_resume_thread`, plus `/akk new-thread`, `/akk clear-thread`, `/akk threads`, and `/akk resume-thread` human-facing commands.
|
|
9
|
+
- Publish the v5 list/action contract with exact terminal lifecycle targets, complete native-thread identities, fresh compare-and-swap binding tokens, per-candidate evidence fingerprints, and capability-gated availability.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Keep ordinary sends scoped to the current native context: each accepted send creates a new Turn, while a successful native lifecycle transition creates or activates an AKK Session and creates no Turn.
|
|
14
|
+
- Treat a sole historical Session whose recorded coding-agent PID has conclusively exited as resumable on the next lifecycle listing; the resume mutation compare-and-swap detaches that stale binding before terminal input, without background polling.
|
|
15
|
+
- Let human-facing lifecycle slash commands fetch a fresh binding token internally immediately before mutation; plugin tools retain the explicit token so OpenClaw follows an auditable list-then-mutate flow.
|
|
16
|
+
- Record terminal binding generations and native-thread transition lineage so sends after new/clear or resume target only the newly verified context.
|
|
17
|
+
- Upgrade the Store writer protocol to v3. The first mutation of a v1 or v2 predecessor durably materializes authoritative Session records before publishing the new manifest, quarantines ambiguous bindings, and leaves existing Turn state and event logs unchanged.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
|
|
21
|
+
- Serialize native-thread transitions against sends, approvals, monitors, callbacks, and recovery; require an exact idle terminal with no unresolved Turn and fail closed on unsupported, ambiguous, stale, active-elsewhere, or unverifiable native identity evidence.
|
|
22
|
+
- Fence old monitors, receipts, approvals, callbacks, and recovery mutations to their original terminal incarnation, native thread, AKK Session, Turn, and binding generation.
|
|
23
|
+
- Reject every first-line native slash command at the ordinary send/respond boundary before creating Session, Turn, ledger, or terminal side effects, including lifecycle-changing `/clear`, `/new`, `/resume`, Codex `/fork`/side threads, and Claude `/branch`.
|
|
24
|
+
|
|
3
25
|
## 0.9.0 - 2026-08-05
|
|
4
26
|
|
|
5
27
|
### Added
|
package/README.md
CHANGED
|
@@ -82,9 +82,11 @@ tmux terminal / process incarnation
|
|
|
82
82
|
└─ Turn 3 (turn_id)
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
Once an AKK session exists, an ordinary `send(session_id, request)` creates a new `turn_id` while preserving the native coding-agent context. On first attach only,
|
|
85
|
+
Once an AKK session exists, an ordinary `send(session_id, request)` creates a new `turn_id` while preserving the native coding-agent context. On first attach only, `send` may instead use a discovery selector explicitly named by the user, or the exact `selector` prefilled by an unmanaged raw-terminal row. AKK binds the verified native context to an AKK session before accepting the new Turn. Never infer a selector, copy one from another row, or pass one as `session_id`. The `turn_id` is not a destination for later ordinary sends; it is the exact identity used for status, approval, cancellation, renewal, callback retry, close, and callback correlation. If a Turn is `waiting_for_openclaw`, `respond(turn_id, answer)` supplies the answer inside that same Turn instead of creating another one.
|
|
86
86
|
|
|
87
|
-
Human-friendly selectors such as `only`, `codex`, `claude`, a terminal ID, or `@short-ref` remain a discovery layer. When an AKK session already exists, `AKK list` pre-fills its authoritative `session_id` for send and the exact `turn_id` for managed controls.
|
|
87
|
+
Human-friendly selectors such as `only`, `codex`, `claude`, a terminal ID, or `@short-ref` remain a discovery layer. A natural-language tool call may preserve one only when the user explicitly named it; otherwise use the exact selector returned by `AKK list`, or omit it and require a unique eligible pane. When an AKK session already exists, `AKK list` pre-fills its authoritative `session_id` for send and the exact `turn_id` for managed controls. The same raw terminal row may advertise status, approval, cancellation, or orphan-close with its own prefilled `conversation_id` compatibility selector. Never infer, guess, or reuse compatibility selectors.
|
|
88
|
+
|
|
89
|
+
Native clear/new/resume operations are explicit lifecycle actions, separate from ordinary Turn creation. A successful new/clear creates a new native thread and AKK Session; resume activates the exact historical native thread and its corresponding Session. Each successful lifecycle transition creates no Turn. The next ordinary send creates the first Turn in the selected context. AKK serializes the transition, verifies the resulting native identity, and advances the terminal binding generation so work and callbacks from the previous context cannot cross the boundary.
|
|
88
90
|
|
|
89
91
|
## Optional: Natural-Language Delegation
|
|
90
92
|
|
|
@@ -210,6 +212,10 @@ The core command surface is intentionally small:
|
|
|
210
212
|
/akk <task>
|
|
211
213
|
/akk <selector>: <message>
|
|
212
214
|
/akk list
|
|
215
|
+
/akk threads <exact-terminal-id>
|
|
216
|
+
/akk new-thread <exact-terminal-id>
|
|
217
|
+
/akk clear-thread <exact-terminal-id>
|
|
218
|
+
/akk resume-thread <exact-terminal-id> [native-thread-uuid]
|
|
213
219
|
/akk status [only|latest|codex|claude|@short-ref]
|
|
214
220
|
/akk respond <turn-selector>: <answer>
|
|
215
221
|
/akk cancel <turn-selector>
|
|
@@ -217,11 +223,15 @@ The core command surface is intentionally small:
|
|
|
217
223
|
|
|
218
224
|
`/akk list` performs a controlled reconciliation across managed turns, and `/akk status` limits reconciliation to the selected turn. This can close records whose idle retention has elapsed and restore eligible missing monitors, but it does not send terminal input or retry callback delivery. Standalone shell queries are read-only unless `--reconcile` is explicitly passed, and resolving a selector never changes turn state.
|
|
219
225
|
|
|
220
|
-
Selectors fail closed: `only` works only with one actionable target, `latest` requires a unique newest target, and `codex` or `claude` must identify exactly one eligible pane. These names and `@short-ref` are human-facing resolution inputs;
|
|
226
|
+
Selectors fail closed: `only` works only with one actionable target, `latest` requires a unique newest target, and `codex` or `claude` must identify exactly one eligible pane. These names and `@short-ref` are human-facing resolution inputs; a natural-language tool call may preserve one explicitly named by the user, but must not infer one. Managed JSON actions contain the authoritative full `session_id` or `turn_id`. For first attach, an unmanaged raw-terminal row's send action may instead contain its own prefilled `selector`; its advertised raw controls may contain that row's prefilled `conversation_id`. Neither compatibility selector may be guessed, copied from another row, or passed in an authoritative ID field. Before every terminal operation, AKK revalidates the expected agent PID and tmux pane identity, then confirms that the process and pane working directories still match; every send also revalidates the idle prompt immediately before typing.
|
|
227
|
+
|
|
228
|
+
To change native context, first copy the full `terminal_id` from `/akk list`; lifecycle commands do not accept `@short-ref` or loose agent selectors. `/akk threads <exact-terminal-id>` lists exact, same-workspace resume candidates. `/akk resume-thread <exact-terminal-id>` without a UUID shows the same candidates and asks you to choose; with a complete returned UUID it performs the transition. `/akk new-thread` and its human alias `/akk clear-thread` start a clean context. For these slash forms, AKK reads a fresh lifecycle snapshot and immediately supplies its compare-and-swap binding token internally, so you do not copy the token yourself. AKK does not poll bindings in the background: if a recorded owner process exits, the next lifecycle listing can classify that sole historical binding as resumable, and the resume mutation compare-and-swap detaches it before touching the terminal. Live, stale-token, unsupported, busy, ambiguous, active-elsewhere, or unverifiable transitions fail closed. Do not send `/clear`, `/new`, `/resume`, `/status`, Codex `/fork`, `/side`, or `/btw`, Claude `/branch`, or any other first-line native slash command as an ordinary task or answer; use an advertised AKK action, express the request in natural language, or enter an unsupported native command manually in tmux.
|
|
221
229
|
|
|
222
230
|
For natural-language tool use, `agent_knock_knock_list` is terminal-first. Each live pane appears exactly once in `terminals[]`; `process_state` reports whether its coding-agent process is alive and `activity_state` reports the parsed screen state. `managed.session_id` identifies the continuing AKK session, `managed.current_turn` is its optional active Turn, and `managed.recent_turn` is retained history; retained Turns do not occupy the terminal. Pass `all=true` to include older entries in `managed.history`. By default, `unavailable_managed_turns[]` contains attention-needed records whose pane cannot be presented as a live terminal; `all=true` also includes retained unavailable history.
|
|
223
231
|
|
|
224
|
-
Use only an `available_actions` entry returned in that snapshot, begin with its prefilled authoritative arguments, and supply every `missing_required` field. A managed Session's `send` uses its prefilled `session_id` and creates a new Turn. For first attach only,
|
|
232
|
+
Use only an `available_actions` entry returned in that snapshot, begin with its prefilled authoritative arguments, and supply every `missing_required` field. A managed Session's `send` uses its prefilled `session_id` and creates a new Turn. For first attach only, use a discovery selector explicitly named by the user or the unmanaged raw-terminal row's prefilled `selector`; do not infer or reuse one. `respond` is available only while a Turn is `waiting_for_openclaw`; it uses `turn_id` and keeps the answer inside that Turn. Managed status, approval, cancellation, renewal, callback retry, and close also use the exact `turn_id`. A raw terminal may be controlled only through the exact status, approval, cancellation, or orphan-close action that its own row advertises with a prefilled `conversation_id`; never construct or guess one. For an ordinary send, add only `request`—`timeoutSeconds` is unsupported, and monitoring limits should be omitted unless the user explicitly asks to change them. AKK revalidates availability before every side effect.
|
|
233
|
+
|
|
234
|
+
The top-level v5 `action_contracts` adds `list_resumable_threads`, `new_thread`, and `resume_thread`. The terminal row advertises `list_resumable_threads` and, when currently safe, `new_thread`. Listing is read-only, takes only the full `terminal_id`, and returns a fresh `expected_binding_token` plus candidate rows; each `resumable=true` candidate row advertises its own `resume_thread` action. The `new_thread` and `resume_thread` mutations require that fresh token, and resume additionally requires the candidate's complete `native_thread_id` and opaque `candidate_token`. Never construct, guess, truncate, combine across snapshots, or reuse those values after another terminal action. A lifecycle result contains Session and native-thread identities but no `turn_id` because no work was sent.
|
|
225
235
|
|
|
226
236
|
Workspace is not a routing boundary. AKK can list, inspect, and control verified panes across projects; when more than one target matches, use a selector to choose one explicitly.
|
|
227
237
|
|
|
@@ -233,7 +243,7 @@ AKK works without project-specific plugin configuration. It reads these optional
|
|
|
233
243
|
|
|
234
244
|
| Option | Default | Purpose |
|
|
235
245
|
| --- | --- | --- |
|
|
236
|
-
| `storeDir` | `~/.agent-knock-knock/store` | Stable Store root for the compatibility manifest
|
|
246
|
+
| `storeDir` | `~/.agent-knock-knock/store` | Stable Store root for the compatibility manifest, authoritative managed Sessions, and Turn records. |
|
|
237
247
|
| `openclawBin` | Auto-detected | OpenClaw CLI used for callback delivery. |
|
|
238
248
|
| `codexHome` | Auto-detected | Optional Codex home used to identify Codex sessions running in tmux. |
|
|
239
249
|
| `idleTimeoutMinutes` | `10080` | Idle retention checked during controlled reconciliation. |
|
|
@@ -295,7 +305,7 @@ With the global npm CLI installed, start with `agent-knock-knock doctor`. It run
|
|
|
295
305
|
| Source changes do not appear | Build, reinstall from the checkout, and restart the Gateway. |
|
|
296
306
|
| Terminal Turn is `stalled` | Inspect `status` and the terminal; use `/akk renew only <minutes>` only when exactly one live stalled Turn needs more monitoring time. |
|
|
297
307
|
| Turn is `callback_failed` | Run `/akk retry-callback only` when it is the only actionable failed callback, or use its `@short-ref`. |
|
|
298
|
-
| `AKK list` reports an orphaned terminal dispatch | Inspect the named pane first, then run the exact `/akk close
|
|
308
|
+
| `AKK list` reports an orphaned terminal dispatch or lifecycle transition | Inspect the named pane first, then run the exact `/akk close ...` recovery command returned by `list`. It contains exactly one fresh `--expected-message-id ...` or `--expected-transition-id ...` fence; do not construct, substitute, or reuse it. AKK leaves the coding agent and tmux pane running. |
|
|
299
309
|
| Claude permission is not offered through AKK | Resolve unsupported dialogs in the terminal. The AKK path requires the exact supported one-time Bash prompt for the current managed turn. |
|
|
300
310
|
| Claude request was not auto-approved | Check `autoApprove.enabled`, the agent, the rule's canonical `workspaces`, and the exact command vector. The request must also have matching current screen and local transcript evidence. |
|
|
301
311
|
| Claude monitor becomes `stalled` | Check the Claude version and `status`, then inspect the terminal. Unknown transcript schemas, background work, identity changes, and ambiguous turns intentionally fail closed. |
|
|
@@ -337,9 +347,9 @@ gh workflow run clawhub-publish.yml --ref vX.Y.Z -f dry_run=false
|
|
|
337
347
|
|
|
338
348
|
## Storage and Logs
|
|
339
349
|
|
|
340
|
-
Managed state now lives in the stable `~/.agent-knock-knock/store` root. Its manifest prevents an incompatible AKK writer from changing Turn state. Directories use mode `0700`; state and log files use `0600`.
|
|
350
|
+
Managed state now lives in the stable `~/.agent-knock-knock/store` root. Its manifest prevents an incompatible AKK writer from changing authoritative Session or Turn state. Directories use mode `0700`; state and log files use `0600`.
|
|
341
351
|
|
|
342
|
-
The manifest checks storage format and writer behavior separately. An unknown `format_version` is not read.
|
|
352
|
+
The manifest checks storage format and writer behavior separately. An unknown `format_version` is not read. The current writer protocol is 3, and writer protocols 1 and 2 are its supported predecessors: inspection reports either as `upgradeable`. Before the first mutation publishes a protocol-3 manifest, AKK validates the predecessor Turn records, deterministically derives and durably materializes authoritative Session records, and quarantines ambiguous Session bindings instead of routing through them. Existing Turn state and event logs remain unchanged, and the manifest's `created_at` is preserved. Any other writer-protocol mismatch remains readable for normal queries, while explicit reconciliation reports `skipped` and every mutation fails closed before terminal or Gateway side effects.
|
|
343
353
|
|
|
344
354
|
The former `~/.agent-knock-knock/conversations` directory is left untouched; AKK does not read or migrate it. Existing Codex and Claude Code tmux panes remain available through live discovery, while their old managed-turn IDs, callback associations, and legacy conversation aliases are not carried into the new Store. Compatible future upgrades continue using the stable Store rather than creating a directory per package version.
|
|
345
355
|
|
|
@@ -31,7 +31,7 @@ export interface CodingAgentSessionProvider {
|
|
|
31
31
|
getCapabilities(): Promise<AgentSessionCapabilities>;
|
|
32
32
|
listHistoricalSessions(): Promise<CodexSessionSummary[]>;
|
|
33
33
|
listActiveSessions(): Promise<ActiveCodexProcess[]>;
|
|
34
|
-
resolveActiveSessionIdentityForPid(pid: number, cwd?: string): Promise<ActiveAgentSessionIdentity | undefined>;
|
|
34
|
+
resolveActiveSessionIdentityForPid(pid: number, cwd?: string, preferredSessionId?: string, allowedCompanionIdentity?: ActiveAgentSessionIdentity, allowedAdditionalIdentities?: readonly ActiveAgentSessionIdentity[]): Promise<ActiveAgentSessionIdentity | undefined>;
|
|
35
35
|
getSession(sessionId: string): Promise<CodexSessionSummary | undefined>;
|
|
36
36
|
getForkContext(options: ForkContextOptions): Promise<ForkContextPackage | undefined>;
|
|
37
37
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClaudeAgentRow } from "./claude-terminal-agent-adapter.js";
|
|
2
|
-
import type { TerminalCompletionEvidence, TerminalDurableCompletionRequest } from "./terminal-agent-adapter.js";
|
|
2
|
+
import type { TerminalCompletionEvidence, TerminalDurableCompletionRequest, TerminalThreadLifecycleCandidate, TerminalThreadLifecycleCandidateProvider, TerminalThreadLifecycleCandidateRequest, TerminalThreadLifecycleCandidateToken, TerminalThreadLifecycleCandidateValidation, TerminalThreadFileToken } from "./terminal-agent-adapter.js";
|
|
3
3
|
export interface ClaudeTranscriptAnchor {
|
|
4
4
|
schema_version: 1;
|
|
5
5
|
session_id: string;
|
|
@@ -26,6 +26,17 @@ export interface DetectClaudeTranscriptCompletionOptions {
|
|
|
26
26
|
agentRows: readonly ClaudeAgentRow[];
|
|
27
27
|
maxTurnBytes?: number;
|
|
28
28
|
}
|
|
29
|
+
export interface ClaudeHistoricalSessionSummary {
|
|
30
|
+
id: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
transcriptPath: string;
|
|
33
|
+
updatedAtMs: number;
|
|
34
|
+
claudeVersion: string;
|
|
35
|
+
rootInteractive: true;
|
|
36
|
+
fileToken: TerminalThreadFileToken;
|
|
37
|
+
metadataFingerprint: string;
|
|
38
|
+
candidateToken: TerminalThreadLifecycleCandidateToken;
|
|
39
|
+
}
|
|
29
40
|
/**
|
|
30
41
|
* Process-local evidence for one pending, foreground Claude Bash permission.
|
|
31
42
|
* `command` is intentionally raw so the caller can apply the exact command
|
|
@@ -47,6 +58,23 @@ export interface ClaudeTranscriptPendingApprovalEvidence {
|
|
|
47
58
|
observedEndOffsetBytes: number;
|
|
48
59
|
}
|
|
49
60
|
export declare function defaultClaudeHome(): string;
|
|
61
|
+
/** List exact, root-interactive Claude sessions from owner-private transcripts. */
|
|
62
|
+
export declare function listClaudeHistoricalSessions(options: {
|
|
63
|
+
cwd: string;
|
|
64
|
+
claudeHome?: string;
|
|
65
|
+
agentVersion?: string;
|
|
66
|
+
}): ClaudeHistoricalSessionSummary[];
|
|
67
|
+
export declare function listClaudeThreadLifecycleCandidates(options: {
|
|
68
|
+
cwd: string;
|
|
69
|
+
claudeHome?: string;
|
|
70
|
+
agentVersion: string;
|
|
71
|
+
}): TerminalThreadLifecycleCandidate[];
|
|
72
|
+
export declare function createClaudeThreadLifecycleCandidateProvider(options?: {
|
|
73
|
+
claudeHome?: string;
|
|
74
|
+
}): TerminalThreadLifecycleCandidateProvider;
|
|
75
|
+
export declare function revalidateClaudeThreadLifecycleCandidate(candidate: TerminalThreadLifecycleCandidate | TerminalThreadLifecycleCandidateToken, options: TerminalThreadLifecycleCandidateRequest & {
|
|
76
|
+
claudeHome?: string;
|
|
77
|
+
}): TerminalThreadLifecycleCandidateValidation;
|
|
50
78
|
/**
|
|
51
79
|
* Records the immutable file boundary immediately before AKK submits a turn.
|
|
52
80
|
* No transcript contents are retained in conversation state.
|
|
@@ -11,10 +11,323 @@ const CLAUDE_TRANSCRIPT_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]
|
|
|
11
11
|
const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
|
|
12
12
|
? fs.constants.O_NOFOLLOW
|
|
13
13
|
: 0;
|
|
14
|
+
const CLAUDE_LIFECYCLE_VERSION = "2.1.218";
|
|
15
|
+
const CLAUDE_HISTORICAL_METADATA_MAX_BYTES = 1024 * 1024;
|
|
14
16
|
export function defaultClaudeHome() {
|
|
15
17
|
const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
16
18
|
return configured || path.join(os.homedir(), ".claude");
|
|
17
19
|
}
|
|
20
|
+
/** List exact, root-interactive Claude sessions from owner-private transcripts. */
|
|
21
|
+
export function listClaudeHistoricalSessions(options) {
|
|
22
|
+
if (!options.cwd || !path.isAbsolute(options.cwd)) {
|
|
23
|
+
throw new Error("Claude lifecycle candidate discovery requires an absolute cwd");
|
|
24
|
+
}
|
|
25
|
+
const cwd = path.resolve(options.cwd);
|
|
26
|
+
const claudeHome = path.resolve(options.claudeHome ?? defaultClaudeHome());
|
|
27
|
+
const agentVersion = options.agentVersion ?? CLAUDE_LIFECYCLE_VERSION;
|
|
28
|
+
if (agentVersion !== CLAUDE_LIFECYCLE_VERSION) {
|
|
29
|
+
throw new Error(`Claude lifecycle candidates require exact version ${CLAUDE_LIFECYCLE_VERSION}`);
|
|
30
|
+
}
|
|
31
|
+
const projectsRoot = projectsRootPath(claudeHome);
|
|
32
|
+
if (!isRealDirectory(projectsRoot)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const projectRelative = path.dirname(expectedTranscriptRelativePath("00000000-0000-0000-0000-000000000000", cwd));
|
|
36
|
+
const projectDirectory = path.join(projectsRoot, projectRelative);
|
|
37
|
+
if (!isRealDirectory(projectDirectory)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return fs.readdirSync(projectDirectory, { withFileTypes: true })
|
|
41
|
+
.flatMap((entry) => {
|
|
42
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const match = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/iu.exec(entry.name);
|
|
46
|
+
if (!match) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const summary = inspectClaudeHistoricalSession({
|
|
51
|
+
projectsRoot,
|
|
52
|
+
cwd,
|
|
53
|
+
sessionId: match[1].toLowerCase(),
|
|
54
|
+
agentVersion
|
|
55
|
+
});
|
|
56
|
+
return summary ? [summary] : [];
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Unsafe, malformed, or unstable transcript files are not candidates.
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
|
64
|
+
}
|
|
65
|
+
export function listClaudeThreadLifecycleCandidates(options) {
|
|
66
|
+
return listClaudeHistoricalSessions(options).map((session) => ({
|
|
67
|
+
agent: "claude",
|
|
68
|
+
nativeThreadId: session.id,
|
|
69
|
+
cwd: session.cwd,
|
|
70
|
+
source: "claude_transcript",
|
|
71
|
+
rootInteractive: true,
|
|
72
|
+
fileToken: session.fileToken,
|
|
73
|
+
agentVersion: session.claudeVersion,
|
|
74
|
+
updatedAtMs: session.updatedAtMs,
|
|
75
|
+
metadataFingerprint: session.metadataFingerprint,
|
|
76
|
+
candidateToken: session.candidateToken
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
export function createClaudeThreadLifecycleCandidateProvider(options = {}) {
|
|
80
|
+
return {
|
|
81
|
+
async listThreadLifecycleCandidates(request) {
|
|
82
|
+
return listClaudeThreadLifecycleCandidates({
|
|
83
|
+
cwd: request.cwd,
|
|
84
|
+
agentVersion: request.agentVersion,
|
|
85
|
+
claudeHome: options.claudeHome
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async revalidateThreadLifecycleCandidate(candidate, request) {
|
|
89
|
+
return revalidateClaudeThreadLifecycleCandidate(candidate, {
|
|
90
|
+
...request,
|
|
91
|
+
claudeHome: options.claudeHome
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function revalidateClaudeThreadLifecycleCandidate(candidate, options) {
|
|
97
|
+
try {
|
|
98
|
+
const token = "candidateToken" in candidate
|
|
99
|
+
? candidate.candidateToken
|
|
100
|
+
: candidate;
|
|
101
|
+
if (token.schema !== "agent-knock-knock/thread-candidate-token" ||
|
|
102
|
+
token.version !== 1 ||
|
|
103
|
+
token.agent !== "claude" ||
|
|
104
|
+
token.source !== "claude_transcript" ||
|
|
105
|
+
token.agentVersion !== options.agentVersion ||
|
|
106
|
+
!path.isAbsolute(options.cwd) ||
|
|
107
|
+
!path.isAbsolute(token.cwd) ||
|
|
108
|
+
path.resolve(token.cwd) !== path.resolve(options.cwd) ||
|
|
109
|
+
!CLAUDE_SESSION_ID_PATTERN.test(token.nativeThreadId) ||
|
|
110
|
+
options.agentVersion !== CLAUDE_LIFECYCLE_VERSION) {
|
|
111
|
+
return {
|
|
112
|
+
status: "unsafe",
|
|
113
|
+
reason: "candidate is not an exact Claude root-interactive session"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const claudeHome = path.resolve(options.claudeHome ?? defaultClaudeHome());
|
|
117
|
+
const summary = inspectClaudeHistoricalSession({
|
|
118
|
+
projectsRoot: projectsRootPath(claudeHome),
|
|
119
|
+
cwd: path.resolve(options.cwd),
|
|
120
|
+
sessionId: token.nativeThreadId.toLowerCase(),
|
|
121
|
+
agentVersion: options.agentVersion
|
|
122
|
+
});
|
|
123
|
+
if (!summary) {
|
|
124
|
+
return {
|
|
125
|
+
status: "unavailable",
|
|
126
|
+
reason: "the Claude transcript no longer exists"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const current = {
|
|
130
|
+
agent: "claude",
|
|
131
|
+
nativeThreadId: summary.id,
|
|
132
|
+
cwd: summary.cwd,
|
|
133
|
+
source: "claude_transcript",
|
|
134
|
+
rootInteractive: true,
|
|
135
|
+
fileToken: summary.fileToken,
|
|
136
|
+
agentVersion: summary.claudeVersion,
|
|
137
|
+
updatedAtMs: summary.updatedAtMs,
|
|
138
|
+
metadataFingerprint: summary.metadataFingerprint,
|
|
139
|
+
candidateToken: summary.candidateToken
|
|
140
|
+
};
|
|
141
|
+
if (!sameClaudeThreadFileToken(token.fileToken, current.fileToken) ||
|
|
142
|
+
token.metadataFingerprint !== current.metadataFingerprint) {
|
|
143
|
+
return {
|
|
144
|
+
status: "changed",
|
|
145
|
+
candidate: current,
|
|
146
|
+
reason: "the Claude transcript changed after candidate discovery"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { status: "valid", candidate: current };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
153
|
+
return {
|
|
154
|
+
status: "unavailable",
|
|
155
|
+
reason: "the Claude transcript no longer exists"
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
status: "unsafe",
|
|
160
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function inspectClaudeHistoricalSession({ projectsRoot, cwd, sessionId, agentVersion }) {
|
|
165
|
+
if (!isRealDirectory(projectsRoot) ||
|
|
166
|
+
!CLAUDE_SESSION_ID_PATTERN.test(sessionId) ||
|
|
167
|
+
agentVersion !== CLAUDE_LIFECYCLE_VERSION) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const relativePath = expectedTranscriptRelativePath(sessionId, cwd);
|
|
171
|
+
const opened = openRelativeTranscript(projectsRoot, relativePath);
|
|
172
|
+
if (!opened) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const transcriptPath = fs.realpathSync(path.join(projectsRoot, opened.relativePath));
|
|
177
|
+
const realProjectsRoot = fs.realpathSync(projectsRoot);
|
|
178
|
+
const relative = path.relative(realProjectsRoot, transcriptPath);
|
|
179
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
180
|
+
throw new Error("Claude transcript resolves outside the projects directory");
|
|
181
|
+
}
|
|
182
|
+
const metadata = readClaudeHistoricalMetadata(opened.fd, opened.stat.size);
|
|
183
|
+
if (!metadata) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (metadata.sessionId !== sessionId ||
|
|
187
|
+
!path.isAbsolute(metadata.cwd) ||
|
|
188
|
+
normalizePath(metadata.cwd) !== normalizePath(cwd) ||
|
|
189
|
+
metadata.version !== agentVersion ||
|
|
190
|
+
metadata.isSidechain !== false ||
|
|
191
|
+
metadata.entrypoint !== "cli" ||
|
|
192
|
+
metadata.agentId !== undefined ||
|
|
193
|
+
metadata.teamName !== undefined ||
|
|
194
|
+
metadata.loopSession ||
|
|
195
|
+
![undefined, "interactive", "main"].includes(metadata.sessionKind)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const stable = fs.fstatSync(opened.fd);
|
|
199
|
+
if (stable.dev !== opened.stat.dev ||
|
|
200
|
+
stable.ino !== opened.stat.ino ||
|
|
201
|
+
stable.size !== opened.stat.size ||
|
|
202
|
+
stable.mtimeMs !== opened.stat.mtimeMs ||
|
|
203
|
+
fs.realpathSync(path.join(projectsRoot, opened.relativePath)) !==
|
|
204
|
+
transcriptPath) {
|
|
205
|
+
throw new Error("Claude transcript changed while it was inspected");
|
|
206
|
+
}
|
|
207
|
+
const fileToken = {
|
|
208
|
+
path: transcriptPath,
|
|
209
|
+
device: String(stable.dev),
|
|
210
|
+
inode: String(stable.ino),
|
|
211
|
+
size: stable.size,
|
|
212
|
+
mtimeMs: stable.mtimeMs
|
|
213
|
+
};
|
|
214
|
+
const metadataFingerprint = createHash("sha256")
|
|
215
|
+
.update(JSON.stringify({
|
|
216
|
+
sessionId,
|
|
217
|
+
cwd: path.resolve(cwd),
|
|
218
|
+
version: metadata.version,
|
|
219
|
+
entrypoint: metadata.entrypoint,
|
|
220
|
+
sessionKind: metadata.sessionKind ?? null,
|
|
221
|
+
transcriptPath
|
|
222
|
+
}))
|
|
223
|
+
.digest("hex");
|
|
224
|
+
const candidateToken = {
|
|
225
|
+
schema: "agent-knock-knock/thread-candidate-token",
|
|
226
|
+
version: 1,
|
|
227
|
+
agent: "claude",
|
|
228
|
+
nativeThreadId: sessionId,
|
|
229
|
+
cwd: path.resolve(cwd),
|
|
230
|
+
source: "claude_transcript",
|
|
231
|
+
agentVersion: metadata.version,
|
|
232
|
+
fileToken,
|
|
233
|
+
metadataFingerprint
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
id: sessionId,
|
|
237
|
+
cwd: path.resolve(cwd),
|
|
238
|
+
transcriptPath,
|
|
239
|
+
updatedAtMs: stable.mtimeMs,
|
|
240
|
+
claudeVersion: metadata.version,
|
|
241
|
+
rootInteractive: true,
|
|
242
|
+
fileToken,
|
|
243
|
+
metadataFingerprint,
|
|
244
|
+
candidateToken
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
fs.closeSync(opened.fd);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function readClaudeHistoricalMetadata(fd, fileSize) {
|
|
252
|
+
if (fileSize <= 0 || !Number.isSafeInteger(fileSize)) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const bytesToRead = Math.min(fileSize, CLAUDE_HISTORICAL_METADATA_MAX_BYTES);
|
|
256
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
257
|
+
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, 0);
|
|
258
|
+
if (bytesRead !== bytesToRead) {
|
|
259
|
+
throw new Error("Claude transcript changed while metadata was read");
|
|
260
|
+
}
|
|
261
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
262
|
+
const lastCompleteNewline = text.lastIndexOf("\n");
|
|
263
|
+
if (lastCompleteNewline < 0) {
|
|
264
|
+
if (fileSize > bytesRead) {
|
|
265
|
+
throw new Error("Claude transcript metadata line exceeds the read limit");
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const completeLines = text.slice(0, lastCompleteNewline).split("\n");
|
|
270
|
+
for (const line of completeLines) {
|
|
271
|
+
if (!line.trim()) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(line);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw new Error("Claude transcript contains invalid root metadata JSONL");
|
|
280
|
+
}
|
|
281
|
+
if (!isRecord(parsed)) {
|
|
282
|
+
throw new Error("Claude transcript root metadata must be an object");
|
|
283
|
+
}
|
|
284
|
+
const sessionId = nonEmptyString(parsed.sessionId);
|
|
285
|
+
if (!sessionId) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const cwd = nonEmptyString(parsed.cwd);
|
|
289
|
+
const version = nonEmptyString(parsed.version);
|
|
290
|
+
const entrypoint = nonEmptyString(parsed.entrypoint);
|
|
291
|
+
const hasIdentityMetadata = cwd !== undefined ||
|
|
292
|
+
version !== undefined ||
|
|
293
|
+
entrypoint !== undefined ||
|
|
294
|
+
typeof parsed.isSidechain === "boolean" ||
|
|
295
|
+
nonEmptyString(parsed.agentId) !== undefined ||
|
|
296
|
+
nonEmptyString(parsed.teamName) !== undefined ||
|
|
297
|
+
nonEmptyString(parsed.sessionKind) !== undefined;
|
|
298
|
+
if (!hasIdentityMetadata) {
|
|
299
|
+
// Claude 2.1.218 can prepend mode/permission-mode records carrying only
|
|
300
|
+
// sessionId. Continue to the first full root transcript record.
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!cwd || !version || !entrypoint || typeof parsed.isSidechain !== "boolean") {
|
|
304
|
+
throw new Error("Claude transcript has incomplete root session metadata");
|
|
305
|
+
}
|
|
306
|
+
const sessionKind = nonEmptyString(parsed.sessionKind);
|
|
307
|
+
return {
|
|
308
|
+
sessionId: sessionId.toLowerCase(),
|
|
309
|
+
cwd,
|
|
310
|
+
version,
|
|
311
|
+
isSidechain: parsed.isSidechain,
|
|
312
|
+
entrypoint,
|
|
313
|
+
agentId: nonEmptyString(parsed.agentId),
|
|
314
|
+
teamName: nonEmptyString(parsed.teamName),
|
|
315
|
+
sessionKind,
|
|
316
|
+
loopSession: parsed.isLoop === true ||
|
|
317
|
+
parsed.isLoopSession === true ||
|
|
318
|
+
parsed.loopSession === true ||
|
|
319
|
+
sessionKind === "loop"
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
function sameClaudeThreadFileToken(left, right) {
|
|
325
|
+
return left.path === right.path &&
|
|
326
|
+
left.device === right.device &&
|
|
327
|
+
left.inode === right.inode &&
|
|
328
|
+
left.size === right.size &&
|
|
329
|
+
left.mtimeMs === right.mtimeMs;
|
|
330
|
+
}
|
|
18
331
|
/**
|
|
19
332
|
* Records the immutable file boundary immediately before AKK submits a turn.
|
|
20
333
|
* No transcript contents are retained in conversation state.
|