@agfpd/iapeer 0.1.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/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// install — the foundation install-phase (contract Установка §INSTALL). The
|
|
2
|
+
// foundation ships as ONE real binary at a STABLE host-wide path
|
|
3
|
+
// (~/.local/bin/iapeer), built standalone from src via `bun build --compile`, so
|
|
4
|
+
// PROD is DECOUPLED from the mutable src working tree: the daemon/infra launchd
|
|
5
|
+
// plists run the INSTALLED binary, and any edit/git-op in the tree no longer hits
|
|
6
|
+
// prod. Update = atomic overwrite in place (build to .tmp → rename over), with ONE
|
|
7
|
+
// .prev for rollback. NO versions/ catalog + resolver-symlink (that pattern is for
|
|
8
|
+
// multi-version toolchains; the foundation is one-latest — and a stable path keeps
|
|
9
|
+
// macOS TCC rights through updates, which a versioned path would re-prompt).
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import { spawnSync } from 'child_process'
|
|
15
|
+
|
|
16
|
+
/** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
|
|
17
|
+
* admin, not tied to a node/bun version), ON $PATH. The launchd plists reference
|
|
18
|
+
* THIS path, not process.execPath / a src file — that is the decoupling. */
|
|
19
|
+
export function iapeerBinPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
20
|
+
const home = env.HOME?.trim() || homedir()
|
|
21
|
+
// IAPEER_BIN_DIR override is for tests/sandbox only (never write a real ~/.local/bin
|
|
22
|
+
// in a test). Default = ~/.local/bin.
|
|
23
|
+
const binDir = env.IAPEER_BIN_DIR?.trim() || join(home, '.local', 'bin')
|
|
24
|
+
return join(binDir, 'iapeer')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InstallResult {
|
|
28
|
+
binPath: string
|
|
29
|
+
/** The previous binary preserved for rollback (when one existed). */
|
|
30
|
+
prevPath?: string
|
|
31
|
+
/** Bytes of the installed binary. */
|
|
32
|
+
size?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the standalone `iapeer` binary from the CLI entrypoint and place it at the
|
|
37
|
+
* stable path ATOMICALLY: `bun build --compile <entry> → <bin>.tmp`, then rename over
|
|
38
|
+
* <bin> (atomic on one fs; a running daemon keeps its old inode, new launches take the
|
|
39
|
+
* new one). An existing binary is preserved as <bin>.prev first. Throws on build
|
|
40
|
+
* failure (never leaves a half-written bin). The build runs from the SRC TREE (the
|
|
41
|
+
* dev/npx bootstrap); the resulting binary is self-contained (no tree dependency).
|
|
42
|
+
*/
|
|
43
|
+
/** Fail-closed sandbox guard (audit #25, symmetric to the registry's): under
|
|
44
|
+
* IAPEER_TEST_SANDBOX=1 refuse to build over the REAL ~/.local/bin/iapeer (the live
|
|
45
|
+
* prod binary). A test/sandbox MUST set IAPEER_BIN_DIR to an isolated path. */
|
|
46
|
+
function assertInstallSandboxIsolated(binPath: string, env: NodeJS.ProcessEnv): void {
|
|
47
|
+
if (env.IAPEER_TEST_SANDBOX !== '1') return
|
|
48
|
+
const realBin = join(env.HOME?.trim() || homedir(), '.local', 'bin', 'iapeer')
|
|
49
|
+
if (binPath === realBin) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`refusing to overwrite the REAL prod binary (${realBin}) under IAPEER_TEST_SANDBOX=1 — ` +
|
|
52
|
+
'set IAPEER_BIN_DIR to an isolated path',
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env): InstallResult {
|
|
58
|
+
const binPath = iapeerBinPath(env)
|
|
59
|
+
assertInstallSandboxIsolated(binPath, env)
|
|
60
|
+
mkdirSync(join(binPath, '..'), { recursive: true })
|
|
61
|
+
const tmp = `${binPath}.tmp`
|
|
62
|
+
const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
})
|
|
65
|
+
if (build.status !== 0 || !existsSync(tmp)) {
|
|
66
|
+
throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
|
|
67
|
+
}
|
|
68
|
+
let prevPath: string | undefined
|
|
69
|
+
if (existsSync(binPath)) {
|
|
70
|
+
prevPath = `${binPath}.prev`
|
|
71
|
+
// COPY (not move) the current binary to .prev so binPath is NEVER absent (audit
|
|
72
|
+
// #7): a move-then-move leaves no binary in the window between the two renames —
|
|
73
|
+
// if the second throws, the prod daemon + infra fleet crash-loop with no bin.
|
|
74
|
+
copyFileSync(binPath, prevPath)
|
|
75
|
+
}
|
|
76
|
+
renameSync(tmp, binPath) // atomic replace in place (POSIX rename over an existing file)
|
|
77
|
+
let size: number | undefined
|
|
78
|
+
try {
|
|
79
|
+
size = statSync(binPath).size
|
|
80
|
+
} catch {
|
|
81
|
+
/* best-effort */
|
|
82
|
+
}
|
|
83
|
+
return { binPath, prevPath, size }
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// install — the stable binary path + (lightly) the build. iapeerBinPath is the
|
|
2
|
+
// decoupling anchor: the launchd plists reference it, not process.execPath / a src
|
|
3
|
+
// file. The full `bun build --compile` is exercised LIVE (it writes a ~60M binary —
|
|
4
|
+
// too heavy for a unit test); here we pin the path resolution.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { iapeerBinPath, installIapeer } from './index.ts'
|
|
9
|
+
|
|
10
|
+
describe('iapeerBinPath', () => {
|
|
11
|
+
test('default = <home>/.local/bin/iapeer (stable host-wide path, on $PATH)', () => {
|
|
12
|
+
expect(iapeerBinPath({ HOME: '/Users/x' } as NodeJS.ProcessEnv)).toBe('/Users/x/.local/bin/iapeer')
|
|
13
|
+
})
|
|
14
|
+
test('IAPEER_BIN_DIR override (tests/sandbox — never a real ~/.local/bin)', () => {
|
|
15
|
+
expect(iapeerBinPath({ HOME: '/Users/x', IAPEER_BIN_DIR: '/tmp/sbx/bin' } as NodeJS.ProcessEnv)).toBe(
|
|
16
|
+
join('/tmp/sbx/bin', 'iapeer'),
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Audit #25 — fail-closed sandbox guard (symmetric to the registry's). installIapeer
|
|
22
|
+
// overwrites the live prod binary ~/.local/bin/iapeer; a sandbox/test that forgets
|
|
23
|
+
// IAPEER_BIN_DIR must be REFUSED, never clobber prod. The guard fires BEFORE the build,
|
|
24
|
+
// so no `bun build --compile` runs here.
|
|
25
|
+
describe('installIapeer fail-closed sandbox guard', () => {
|
|
26
|
+
test('THROWS under IAPEER_TEST_SANDBOX=1 when binPath falls through to the REAL ~/.local/bin/iapeer', () => {
|
|
27
|
+
const env = { IAPEER_TEST_SANDBOX: '1', HOME: '/Users/fake-home' } as NodeJS.ProcessEnv
|
|
28
|
+
expect(iapeerBinPath(env)).toBe('/Users/fake-home/.local/bin/iapeer')
|
|
29
|
+
expect(() => installIapeer('/x/entry.ts', env)).toThrow(/refusing to overwrite the REAL prod binary/)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// claude RuntimeAdapter — the "HOW to launch / observe ONE claude session" half
|
|
2
|
+
// of the launch contract (src/launch/types.ts). Runtime-agnostic launch.launch
|
|
3
|
+
// drives it: argv → boot dialogs → ready marker → activity-proxy ready-gate →
|
|
4
|
+
// permission autopilot → resume preflight. Consolidated from three frozen
|
|
5
|
+
// sources, with the exact slug fix that already lives in lifecycle:
|
|
6
|
+
//
|
|
7
|
+
// - Persistent-Peer/bin/claude-start.sh — argv (292-318: --dangerously-skip-
|
|
8
|
+
// permissions, --disallowedTools AskUserQuestion, --system-prompt-file), the
|
|
9
|
+
// ready-marker (`❯` + dev-channels-gone, 357-371) and the boot dialog answers
|
|
10
|
+
// (the dev-channels "I am using this for local development" Enter, 335-345).
|
|
11
|
+
// - Spawned-Peer/src/spawner.ts — buildClaudeArgv (759-787: same flags +
|
|
12
|
+
// optional --resume <uuid>) and findLatestTranscript (522-538: resume uuid).
|
|
13
|
+
// - IAPeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
|
|
14
|
+
// newestClaudeTranscriptMtime / findLatestClaudeTranscript. This is the
|
|
15
|
+
// canonical port: the slug is realpath(cwd).replace(/[^a-zA-Z0-9]/g,'-') —
|
|
16
|
+
// claude encodes EVERY non-alphanumeric char (not just '/'); the old
|
|
17
|
+
// Spawned-Peer replace(/\//g,'-') silently broke on a cwd carrying '_' or '.'
|
|
18
|
+
// (a mkdtemp temp dir). REUSE this exact logic — do not reintroduce the bug.
|
|
19
|
+
//
|
|
20
|
+
// NO currency on this path: no marketplace check, no plugin install/update — that
|
|
21
|
+
// is install-time (blueprint §0.6 fast-wake), not session bring-up.
|
|
22
|
+
|
|
23
|
+
import { homedir } from 'os'
|
|
24
|
+
import { join } from 'path'
|
|
25
|
+
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
26
|
+
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The known claude startup dialogs whose default-highlighted option is the
|
|
34
|
+
* proceed path, so a single Enter clears each:
|
|
35
|
+
* - 'trust this folder' — first-run folder-trust modal.
|
|
36
|
+
* - 'Allow external CLAUDE.md file imports?' — external-import consent.
|
|
37
|
+
* - 'I am using this for local development' — dev-channels accept
|
|
38
|
+
* (claude-start.sh:337), shown when PEER_START_ARGS carries
|
|
39
|
+
* --dangerously-load-development-channels.
|
|
40
|
+
* - 'Resume from summary' / 'Resuming the full session' — the --resume picker.
|
|
41
|
+
* Same set, same order as lifecycle.claudeBootDialog (index.ts:281-289).
|
|
42
|
+
*/
|
|
43
|
+
const CLAUDE_BOOT_DIALOG_MARKERS = [
|
|
44
|
+
'trust this folder',
|
|
45
|
+
'Allow external CLAUDE.md file imports?',
|
|
46
|
+
'I am using this for local development',
|
|
47
|
+
'Resume from summary',
|
|
48
|
+
'Resuming the full session',
|
|
49
|
+
] as const
|
|
50
|
+
|
|
51
|
+
function anyBootDialog(pane: string): boolean {
|
|
52
|
+
return CLAUDE_BOOT_DIALOG_MARKERS.some(m => pane.includes(m))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Claude's project-dir slug: realpath(cwd) with EVERY non-alphanumeric char
|
|
61
|
+
* replaced by '-' (index.ts:232-245). NOT just '/' — a segment with '_' or '.'
|
|
62
|
+
* is slugged too; the Spawned-Peer canon's replace(/\//g,'-') silently worked
|
|
63
|
+
* only because the live fleet's ~/Peers/<name> paths have none, then broke on a
|
|
64
|
+
* mkdtemp temp cwd. realpath first so a symlinked cwd maps to the dir claude
|
|
65
|
+
* actually wrote (a stale path falls through to the original string).
|
|
66
|
+
*/
|
|
67
|
+
function transcriptSlug(workDir: string): string {
|
|
68
|
+
let phys = workDir
|
|
69
|
+
try {
|
|
70
|
+
phys = realpathSync(workDir)
|
|
71
|
+
} catch {
|
|
72
|
+
/* stale path — slug the original */
|
|
73
|
+
}
|
|
74
|
+
return phys.replace(/[^a-zA-Z0-9]/g, '-')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function transcriptDir(workDir: string): string {
|
|
78
|
+
return join(homedir(), '.claude', 'projects', transcriptSlug(workDir))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// claudeAdapter
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export const claudeAdapter: RuntimeAdapter = {
|
|
86
|
+
runtime: 'claude',
|
|
87
|
+
kind: 'tui',
|
|
88
|
+
usesDoctrine: true,
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Delivery markers for submitIntoTui (07.06 refactor — moved out of transport's
|
|
92
|
+
* PROMPT_GLYPHS union into the adapter; docs/Рантайм claude "Доставка"):
|
|
93
|
+
* - promptGlyphs ['❯'] — the same U+276F input-prompt glyph as isInputReady;
|
|
94
|
+
* submitIntoTui finds the prompt row by it. claude-only (codex uses '›'), so
|
|
95
|
+
* a stray codex glyph in a claude pane no longer false-matches.
|
|
96
|
+
* - pastePatterns '[Pasted text' / '[Image #' — claude's bracketed-paste land
|
|
97
|
+
* confirmations (claude-start.sh / transport.ts:191), checked alongside the
|
|
98
|
+
* envelope tail-marker.
|
|
99
|
+
*/
|
|
100
|
+
deliveryMarkers: {
|
|
101
|
+
promptGlyphs: ['❯'],
|
|
102
|
+
pastePatterns: [/\[Pasted text/, /\[Image #/],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* argv = claudeBin + headless flags + (system-prompt-file when set) +
|
|
107
|
+
* (--resume <ref> when resumeRef set) + extraArgs.
|
|
108
|
+
*
|
|
109
|
+
* - '--dangerously-skip-permissions' claude-start.sh:318, spawner.ts:776 —
|
|
110
|
+
* headless peer has no interactive owner to grant per-tool permission.
|
|
111
|
+
* - '--disallowedTools','AskUserQuestion' claude-start.sh:313/316,
|
|
112
|
+
* spawner.ts:777 — AskUserQuestion would render in a TUI no headless peer
|
|
113
|
+
* owner watches; the question goes "into the void". Default is the literal
|
|
114
|
+
* 'AskUserQuestion'; the per-peer override (PEER_DISALLOWED_TOOLS empty =
|
|
115
|
+
* allow all) is install-time launch.env, not this path.
|
|
116
|
+
* - '--system-prompt-file', spec.systemPromptFile claude-start.sh:318 —
|
|
117
|
+
* ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
|
|
118
|
+
* CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
|
|
119
|
+
* layers stay intact (claude-start.sh:293-303).
|
|
120
|
+
* - '--resume', spec.resumeRef spawner.ts:779-781 — ONLY when the caller
|
|
121
|
+
* pre-resolved a uuid (resolveResume); never a silent fresh fallback.
|
|
122
|
+
* - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs).
|
|
123
|
+
* NO currency — no marketplace/install/update on this path.
|
|
124
|
+
*/
|
|
125
|
+
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
126
|
+
return [
|
|
127
|
+
cfg.claudeBin,
|
|
128
|
+
'--dangerously-skip-permissions',
|
|
129
|
+
'--disallowedTools',
|
|
130
|
+
'AskUserQuestion',
|
|
131
|
+
...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
|
|
132
|
+
...(spec.resumeRef ? ['--resume', spec.resumeRef] : []),
|
|
133
|
+
...(spec.extraArgs ?? []),
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A visible startup dialog → ['Enter'] to clear it (its default-highlighted
|
|
139
|
+
* option is the proceed path), else null. Same marker set as
|
|
140
|
+
* lifecycle.claudeBootDialog; claude-start.sh:341 (dev-channels) and the
|
|
141
|
+
* folder-trust/import/resume modals all accept a bare Enter.
|
|
142
|
+
*/
|
|
143
|
+
bootDialogKeys(pane: string): string[] | null {
|
|
144
|
+
return anyBootDialog(pane) ? ['Enter'] : null
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Ready for the first message iff the input surface is rendered AND no startup
|
|
149
|
+
* dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
|
|
150
|
+
* claude-start.sh:365-366):
|
|
151
|
+
* - '❯' (U+276F) — the TUI input-prompt glyph, present only at the ready
|
|
152
|
+
* input row, never in the splash art (claude-start.sh:357-360).
|
|
153
|
+
* - 'bypass permissions on' — the banner --dangerously-skip-permissions
|
|
154
|
+
* emits once booted past the splash.
|
|
155
|
+
* - none of the boot dialogs present (a dialog row can also carry '❯').
|
|
156
|
+
*/
|
|
157
|
+
isInputReady(pane: string): boolean {
|
|
158
|
+
if (anyBootDialog(pane)) return false
|
|
159
|
+
return pane.includes('❯') && pane.includes('bypass permissions on')
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Newest ~/.claude/projects/<slug>/*.jsonl mtimeMs, or null when the dir is
|
|
164
|
+
* absent / empty (index.ts:247-266). The ready-gate waits for this to strictly
|
|
165
|
+
* advance past baseline (model produced its first turn); idle accounting reads
|
|
166
|
+
* the same proxy. slug = transcriptSlug(cwd) (the claude non-alnum encoding).
|
|
167
|
+
*/
|
|
168
|
+
newestActivityMtime(cwd: string): number | null {
|
|
169
|
+
let entries: string[]
|
|
170
|
+
try {
|
|
171
|
+
entries = readdirSync(transcriptDir(cwd))
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
let newest = 0
|
|
176
|
+
for (const name of entries) {
|
|
177
|
+
if (!name.endsWith('.jsonl')) continue
|
|
178
|
+
try {
|
|
179
|
+
const mt = statSync(join(transcriptDir(cwd), name)).mtimeMs
|
|
180
|
+
if (mt > newest) newest = mt
|
|
181
|
+
} catch {
|
|
182
|
+
/* race — entry vanished between readdir and stat */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return newest > 0 ? newest : null
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A claude tool-permission/approval modal is open iff the pane shows
|
|
190
|
+
* 'Do you want to proceed?' (Spawned-Peer dialogs.ts:60-61,
|
|
191
|
+
* approve-watch.sh:78). Defensive only: a --dangerously-skip-permissions peer
|
|
192
|
+
* does not normally reach it, but a stray approval (e.g. a connector) must not
|
|
193
|
+
* stall a headless session.
|
|
194
|
+
*/
|
|
195
|
+
permissionDialogActive(pane: string): boolean {
|
|
196
|
+
return pane.includes('Do you want to proceed?')
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Affirm a claude approval with ['Enter']: option 1 ('Yes') is the
|
|
201
|
+
* default-highlighted choice, so a bare Enter accepts (approve-watch.sh:22-25,
|
|
202
|
+
* Spawned-Peer watcher.ts:298).
|
|
203
|
+
*/
|
|
204
|
+
permissionDialogKeys(): string[] {
|
|
205
|
+
return ['Enter']
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resume preflight (fail-loud — never a silent fresh fallback): resolve the
|
|
210
|
+
* newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
|
|
211
|
+
* findLatestClaudeTranscript / spawner.findLatestTranscript). {ok:true, ref}
|
|
212
|
+
* when one exists, else {ok:false, reason:'no transcript to resume'} so the
|
|
213
|
+
* caller surfaces a real failure instead of starting a context-less session.
|
|
214
|
+
*/
|
|
215
|
+
resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
|
216
|
+
let entries: string[]
|
|
217
|
+
try {
|
|
218
|
+
entries = readdirSync(transcriptDir(cwd))
|
|
219
|
+
} catch {
|
|
220
|
+
return { ok: false, reason: 'no transcript to resume' }
|
|
221
|
+
}
|
|
222
|
+
let best: { name: string; mt: number } | null = null
|
|
223
|
+
for (const name of entries) {
|
|
224
|
+
if (!name.endsWith('.jsonl')) continue
|
|
225
|
+
try {
|
|
226
|
+
const mt = statSync(join(transcriptDir(cwd), name)).mtimeMs
|
|
227
|
+
if (!best || mt > best.mt) best = { name, mt }
|
|
228
|
+
} catch {
|
|
229
|
+
/* race */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!best) return { ok: false, reason: 'no transcript to resume' }
|
|
233
|
+
return { ok: true, ref: best.name.replace(/\.jsonl$/, '') }
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Map a control command to claude's in-session mechanism (Ф-E, docs/Control-команды
|
|
238
|
+
* + docs/TUI-взаимодействие):
|
|
239
|
+
* - interrupt → ['Escape'] (claude interrupts the current turn with ONE Escape;
|
|
240
|
+
* the session + context stay intact — distinct from the `stop` verb which halts
|
|
241
|
+
* the session). The "кнопка заткнуть бредящего" without losing context.
|
|
242
|
+
* - compact → type '/compact' then Enter (claude's context-compaction slash).
|
|
243
|
+
* - anything else → null (unsupported → explicit refusal upstream).
|
|
244
|
+
*/
|
|
245
|
+
executeControl(command: ControlCommand): ControlPlan | null {
|
|
246
|
+
if (command.name === 'interrupt') return { sequence: [['Escape']] }
|
|
247
|
+
if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
|
|
248
|
+
return null
|
|
249
|
+
},
|
|
250
|
+
}
|