@agfpd/iapeer 0.2.39 → 0.2.41
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/package.json +1 -1
- package/src/cli/index.ts +10 -0
- package/src/init/index.ts +38 -0
- package/src/init/init.test.ts +42 -0
- package/src/launch/cmdlog.test.ts +94 -0
- package/src/launch/cmdlog.ts +130 -0
- package/src/launch/index.ts +20 -2
- package/src/lifecycle/index.ts +6 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1339,6 +1339,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1339
1339
|
return 1
|
|
1340
1340
|
}
|
|
1341
1341
|
out(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
|
|
1342
|
+
// Attach-window observability (killer-hunt 11.06: «была ли смерть под
|
|
1343
|
+
// attach» was answerable only INDIRECTLY via wake cause=attach — i.e. only
|
|
1344
|
+
// when the session was dead at attach time). Bracket the operator's client
|
|
1345
|
+
// window in lifecycle.log: ev=attach at entry, ev=attach-end (with the tmux
|
|
1346
|
+
// client's exit code) when the operator detaches or the server dies under
|
|
1347
|
+
// the client. A death timestamp inside an attach…attach-end window now
|
|
1348
|
+
// reads directly off the log.
|
|
1349
|
+
const attachCfg = loadLifecycleConfig(env)
|
|
1350
|
+
appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach', identity: r.identity, woke: String(r.woke) }, { env })
|
|
1342
1351
|
// Drop the operator into the session. `env -u TMUX` so a nested attach from
|
|
1343
1352
|
// inside tmux does not error ("sessions should be nested with care").
|
|
1344
1353
|
const attachEnv = { ...env }
|
|
@@ -1347,6 +1356,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1347
1356
|
stdio: 'inherit',
|
|
1348
1357
|
env: attachEnv as Record<string, string>,
|
|
1349
1358
|
})
|
|
1359
|
+
appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: a.status ?? -1 }, { env })
|
|
1350
1360
|
return a.status ?? 0
|
|
1351
1361
|
}
|
|
1352
1362
|
default: {
|
package/src/init/index.ts
CHANGED
|
@@ -172,6 +172,41 @@ export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions =
|
|
|
172
172
|
return { path, added: true }
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Idempotently ensure codex's startup update-check DIALOG is disabled host-wide:
|
|
177
|
+
* `check_for_update_on_startup = false` as a TOP-LEVEL key in config.toml.
|
|
178
|
+
*
|
|
179
|
+
* Why (incident 11.06, codex-linus): the interactive "✨ Update available! …
|
|
180
|
+
* Press enter to continue" dialog is a BOOT GATE — a daemon-woken codex session
|
|
181
|
+
* sits on it until a human attaches. The wake reported never-became-ready after
|
|
182
|
+
* 4+ minutes and a notifier alert of the EMERGENCY contour (mining farm) was
|
|
183
|
+
* lost; the session came alive only from a manual attach. Warm-on-demand peers
|
|
184
|
+
* must reach ready hands-free — codex updates stay operator-driven (`codex
|
|
185
|
+
* update` / brew), never a boot-time question to a headless session.
|
|
186
|
+
*
|
|
187
|
+
* Mechanics: the key is a documented top-level config.toml option (present in
|
|
188
|
+
* the binary's own config schema; suppression PROVEN LIVE 11.06 on codex 0.138
|
|
189
|
+
* with the 0.139 update pending — control boot showed the dialog, the keyed
|
|
190
|
+
* boot went straight past it). A TOML top-level key must precede the first
|
|
191
|
+
* table header, so the key is PREPENDED — appending would land it inside the
|
|
192
|
+
* last [section]. An EXISTING `check_for_update_on_startup` of either value is
|
|
193
|
+
* respected (never override an explicit operator choice).
|
|
194
|
+
*/
|
|
195
|
+
export function ensureCodexUpdateCheckDisabled(
|
|
196
|
+
options: StorageOptions = {},
|
|
197
|
+
): { path: string; changed: boolean } {
|
|
198
|
+
const path = codexConfigPath(options)
|
|
199
|
+
let existing = ''
|
|
200
|
+
try {
|
|
201
|
+
existing = readFileSync(path, 'utf8')
|
|
202
|
+
} catch {
|
|
203
|
+
/* no config yet → create it */
|
|
204
|
+
}
|
|
205
|
+
if (/^\s*check_for_update_on_startup\s*=/m.test(existing)) return { path, changed: false }
|
|
206
|
+
writeFileAtomic(path, `check_for_update_on_startup = false\n${existing}`, 0o644)
|
|
207
|
+
return { path, changed: true }
|
|
208
|
+
}
|
|
209
|
+
|
|
175
210
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
211
|
// Local doctrine template (<cwd>/.iapeer/IAPEER.md — personality/role; human fills)
|
|
177
212
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -304,6 +339,9 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
304
339
|
let codexMcpConfigPath: string | undefined
|
|
305
340
|
if (provisioned.runtime === 'codex' || hasCodexMarker(provisioned.cwd)) {
|
|
306
341
|
codexMcpConfigPath = writeCodexMcpConfig(daemonUrl, { env }).path
|
|
342
|
+
// Boot-gate hygiene (incident 11.06): a codex peer must never sit on the
|
|
343
|
+
// interactive update dialog at wake — disable the startup check host-wide.
|
|
344
|
+
ensureCodexUpdateCheckDisabled({ env })
|
|
307
345
|
}
|
|
308
346
|
|
|
309
347
|
const doctrine = ensureDoctrineTemplate(provisioned.cwd)
|
package/src/init/init.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
CODEX_BEARER_ENV_VAR,
|
|
10
10
|
IAPEER_MCP_SERVER_NAME,
|
|
11
11
|
codexConfigPath,
|
|
12
|
+
ensureCodexUpdateCheckDisabled,
|
|
12
13
|
ensureDoctrineTemplate,
|
|
13
14
|
initPeer,
|
|
14
15
|
resolveDaemonMcpUrl,
|
|
@@ -169,3 +170,44 @@ describe('initPeer orchestration', () => {
|
|
|
169
170
|
expect(readFileSync(join(cwd, '.iapeer', 'IAPEER.md'), 'utf8')).toBe('custom doctrine')
|
|
170
171
|
})
|
|
171
172
|
})
|
|
173
|
+
|
|
174
|
+
describe('ensureCodexUpdateCheckDisabled (boot-gate hygiene — incident 11.06 codex-linus)', () => {
|
|
175
|
+
test('PREPENDS the top-level key BEFORE the first [section] (TOML top-level scope)', () => {
|
|
176
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome3-'))
|
|
177
|
+
try {
|
|
178
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
|
|
179
|
+
writeFileSync(codexConfigPath({ env }), 'model = "gpt-5.5"\n\n[projects."/x"]\ntrust_level = "trusted"\n')
|
|
180
|
+
const { changed } = ensureCodexUpdateCheckDisabled({ env })
|
|
181
|
+
expect(changed).toBe(true)
|
|
182
|
+
const toml = readFileSync(codexConfigPath({ env }), 'utf8')
|
|
183
|
+
expect(toml.startsWith('check_for_update_on_startup = false\n')).toBe(true)
|
|
184
|
+
expect(toml).toContain('[projects."/x"]') // existing config preserved
|
|
185
|
+
// idempotent: second run is a no-op
|
|
186
|
+
expect(ensureCodexUpdateCheckDisabled({ env }).changed).toBe(false)
|
|
187
|
+
expect((toml.match(/check_for_update_on_startup/g) ?? []).length).toBe(1)
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
test('respects an EXPLICIT existing value (never overrides the operator)', () => {
|
|
193
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome4-'))
|
|
194
|
+
try {
|
|
195
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
|
|
196
|
+
writeFileSync(codexConfigPath({ env }), 'check_for_update_on_startup = true\n')
|
|
197
|
+
expect(ensureCodexUpdateCheckDisabled({ env }).changed).toBe(false)
|
|
198
|
+
expect(readFileSync(codexConfigPath({ env }), 'utf8')).toContain('check_for_update_on_startup = true')
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
test('absent config → created with just the key', () => {
|
|
204
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'iapeer-codexhome5-'))
|
|
205
|
+
try {
|
|
206
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: codexHome }
|
|
207
|
+
expect(ensureCodexUpdateCheckDisabled({ env }).changed).toBe(true)
|
|
208
|
+
expect(readFileSync(codexConfigPath({ env }), 'utf8')).toBe('check_for_update_on_startup = false\n')
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(codexHome, { recursive: true, force: true })
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// tmux command-log (cmdlog.ts) — the killer-hunt socket-command layer.
|
|
2
|
+
//
|
|
3
|
+
// The live suite encodes boris's acceptance criterion directly: a kill-session
|
|
4
|
+
// issued by an EXTERNAL client must land in the -v server log WITH client
|
|
5
|
+
// attribution (`message: client-<pid> command: …`), file modes never wider than
|
|
6
|
+
// 0600, and the cap keeps volume bounded without losing the tail.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
9
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, existsSync } from 'fs'
|
|
10
|
+
import { tmpdir } from 'os'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { spawnSync } from 'child_process'
|
|
13
|
+
import {
|
|
14
|
+
CMDLOG_KEEP_BYTES,
|
|
15
|
+
capCmdLogs,
|
|
16
|
+
cmdLogDirFor,
|
|
17
|
+
hardenCmdLogDir,
|
|
18
|
+
prepareCmdLogDir,
|
|
19
|
+
} from './cmdlog.ts'
|
|
20
|
+
|
|
21
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
22
|
+
|
|
23
|
+
let root: string
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
root = mkdtempSync(join(tmpdir(), 'cmdlog-'))
|
|
26
|
+
})
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
rmSync(root, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('prepareCmdLogDir / hardenCmdLogDir / capCmdLogs (hermetic)', () => {
|
|
32
|
+
test('prepare WIPES the previous generation and recreates 0700', () => {
|
|
33
|
+
const dir = prepareCmdLogDir(root, 'claude-x')
|
|
34
|
+
expect(dir).toBe(cmdLogDirFor(root, 'claude-x'))
|
|
35
|
+
writeFileSync(join(dir!, 'tmux-server-1.log'), 'old generation')
|
|
36
|
+
const again = prepareCmdLogDir(root, 'claude-x')
|
|
37
|
+
expect(readdirSync(again!)).toEqual([]) // old log gone — one generation per dir
|
|
38
|
+
expect(statSync(again!).mode & 0o777).toBe(0o700)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('harden clamps every file to 0600', () => {
|
|
42
|
+
const dir = prepareCmdLogDir(root, 'claude-h')!
|
|
43
|
+
writeFileSync(join(dir, 'tmux-server-2.log'), 'x', { mode: 0o644 })
|
|
44
|
+
hardenCmdLogDir(dir)
|
|
45
|
+
expect(statSync(join(dir, 'tmux-server-2.log')).mode & 0o777).toBe(0o600)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('cap tail-keeps an oversized log (KEEP bytes of TAIL survive), leaves small logs alone', () => {
|
|
49
|
+
const dir = prepareCmdLogDir(root, 'claude-c')!
|
|
50
|
+
const big = join(dir, 'tmux-server-3.log')
|
|
51
|
+
const small = join(dir, 'tmux-server-4.log')
|
|
52
|
+
// 2 MB of filler ending in a recognizable tail — cap to 1 KB keep for the test
|
|
53
|
+
writeFileSync(big, Buffer.concat([Buffer.alloc(2 * 1024 * 1024, 0x61), Buffer.from('THE-DEATH-TAIL')]))
|
|
54
|
+
writeFileSync(small, 'tiny')
|
|
55
|
+
const capped = capCmdLogs(root, 1024 * 1024, 1024)
|
|
56
|
+
expect(capped).toEqual([big])
|
|
57
|
+
const after = readFileSync(big)
|
|
58
|
+
expect(after.length).toBe(1024)
|
|
59
|
+
expect(after.toString()).toEndWith('THE-DEATH-TAIL') // the tail is what survives
|
|
60
|
+
expect(readFileSync(small, 'utf8')).toBe('tiny')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('cap on a missing tree is a silent no-op', () => {
|
|
64
|
+
expect(capCmdLogs(join(root, 'absent'))).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('default keep constant stays under the cap', () => {
|
|
68
|
+
expect(CMDLOG_KEEP_BYTES).toBeLessThan(8 * 1024 * 1024)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe.if(tmuxAvailable)('live -v server log (acceptance criterion)', () => {
|
|
73
|
+
test('external client kill-session lands in the server log with client attribution; harden → 0600', () => {
|
|
74
|
+
const dir = prepareCmdLogDir(root, 'claude-live')!
|
|
75
|
+
const sock = join(root, 'live.sock')
|
|
76
|
+
// Start the server FROM the cmdlog dir with -v — exactly the launch wiring.
|
|
77
|
+
const start = spawnSync('tmux', ['-S', sock, '-v', 'new-session', '-d', '-x', '80', '-y', '24', '-s', 'claude-live', 'sleep 60'], {
|
|
78
|
+
cwd: dir,
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
})
|
|
81
|
+
expect(start.status).toBe(0)
|
|
82
|
+
hardenCmdLogDir(dir)
|
|
83
|
+
// An EXTERNAL client (separate tmux invocation = separate client pid) kills it.
|
|
84
|
+
const kill = spawnSync('tmux', ['-S', sock, 'kill-session', '-t', 'claude-live'], { encoding: 'utf8' })
|
|
85
|
+
expect(kill.status).toBe(0)
|
|
86
|
+
const serverLog = readdirSync(dir).find(f => f.startsWith('tmux-server-'))
|
|
87
|
+
expect(serverLog).toBeDefined()
|
|
88
|
+
expect(statSync(join(dir, serverLog!)).mode & 0o777).toBe(0o600)
|
|
89
|
+
const text = readFileSync(join(dir, serverLog!), 'utf8')
|
|
90
|
+
// Attribution line: message: client-<pid> command: kill-session -t claude-live
|
|
91
|
+
expect(text).toMatch(/message: client-\d+ command: kill-session -t claude-live/)
|
|
92
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// tmux command-log — the SOCKET-COMMAND observability layer of the killer hunt.
|
|
2
|
+
//
|
|
3
|
+
// Why (death class 11.06, boris's session-deaths track): a tmux server killed by a
|
|
4
|
+
// CLIENT COMMAND (`kill-server` / `kill-session` over the unix socket) dies with
|
|
5
|
+
// zero signals — structurally invisible to ALL THREE existing layers: the eslogger
|
|
6
|
+
// trap (no kill(2) anywhere; kernel-originated SIGHUP to the panes is also proven
|
|
7
|
+
// invisible), the pane-died hook (kill-session destroys the pane without retention)
|
|
8
|
+
// and the server-death canary (sees only "server gone"). The tmux server's OWN
|
|
9
|
+
// verbose log is the only witness of that layer: started with `-v`, the server
|
|
10
|
+
// appends `message: client-<pid> command: <verb> <args>` for every client command —
|
|
11
|
+
// the attribution (client pid + exact command + timestamp) that decides the class
|
|
12
|
+
// binarily: "killed by client X" vs "no command ⇒ internal exit".
|
|
13
|
+
//
|
|
14
|
+
// Sensitivity (boris's requirement): the -v log carries FULL send-keys payloads
|
|
15
|
+
// (every delivered envelope, plaintext) — tmux has no commands-only level and the
|
|
16
|
+
// server writes the file itself (name tmux-server-<pid>.log in the STARTING
|
|
17
|
+
// client's cwd — no FIFO/pipe interposition possible). Containment instead:
|
|
18
|
+
// • the per-identity dir is 0700 (others cannot traverse — same protection class
|
|
19
|
+
// as the transcripts that already hold the same texts);
|
|
20
|
+
// • files are chmod'ed 0600 right after the server starts (hardenCmdLogDir);
|
|
21
|
+
// • volume is capped: ~7 KB/s under a 10 fps TUI redraw (measured) — the
|
|
22
|
+
// supervise tick tail-keeps each log over CAP_BYTES down to KEEP_BYTES.
|
|
23
|
+
// tmux writes with O_APPEND (proven live: external truncation is safe, the
|
|
24
|
+
// server keeps appending at the new EOF), and a killer's command is by nature
|
|
25
|
+
// the LAST thing in the log — the death tail always survives capping.
|
|
26
|
+
// • scope: agent-runtime (warm) sessions only — always-on infra sessions relaunch
|
|
27
|
+
// rarely (no rotation point) and are not the dying class.
|
|
28
|
+
// Rotation: prepareCmdLogDir WIPES the identity dir on every launch — one server
|
|
29
|
+
// generation per dir, no unbounded accumulation across relaunches.
|
|
30
|
+
//
|
|
31
|
+
// Best-effort BY CONSTRUCTION: observability must never fail a launch or a
|
|
32
|
+
// supervise tick — every export swallows FS errors.
|
|
33
|
+
|
|
34
|
+
import { chmodSync, mkdirSync, openSync, closeSync, readSync, readdirSync, rmSync, statSync, writeFileSync, fstatSync } from 'fs'
|
|
35
|
+
import { join } from 'path'
|
|
36
|
+
|
|
37
|
+
/** Tail-keep cap: a log over CAP_BYTES is cut down to its last KEEP_BYTES. */
|
|
38
|
+
export const CMDLOG_CAP_BYTES = 8 * 1024 * 1024
|
|
39
|
+
export const CMDLOG_KEEP_BYTES = 1024 * 1024
|
|
40
|
+
|
|
41
|
+
const CMDLOG_SUBDIR = 'tmux-cmdlog'
|
|
42
|
+
|
|
43
|
+
/** The per-identity command-log dir: <eventLogDir>/tmux-cmdlog/<identity>. The
|
|
44
|
+
* session-creating tmux client runs FROM this dir with `-v`, so the server drops
|
|
45
|
+
* tmux-server-<pid>.log (and the client its tmux-client-*.log) here. */
|
|
46
|
+
export function cmdLogDirFor(eventLogDir: string, identity: string): string {
|
|
47
|
+
return join(eventLogDir, CMDLOG_SUBDIR, identity)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wipe + recreate the identity's command-log dir (0700) for a fresh server
|
|
51
|
+
* generation. Returns the dir, or undefined when the FS refuses (the caller then
|
|
52
|
+
* launches WITHOUT -v — observability is never load-bearing). */
|
|
53
|
+
export function prepareCmdLogDir(eventLogDir: string, identity: string): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const dir = cmdLogDirFor(eventLogDir, identity)
|
|
56
|
+
rmSync(dir, { recursive: true, force: true })
|
|
57
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
58
|
+
return dir
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Clamp every file the freshly-started server/client dropped into the dir to
|
|
65
|
+
* 0600 (the dir's 0700 already blocks traversal; this is the belt to that
|
|
66
|
+
* suspender — boris's acceptance criterion: no file wider than 0600). */
|
|
67
|
+
export function hardenCmdLogDir(dir: string): void {
|
|
68
|
+
try {
|
|
69
|
+
for (const name of readdirSync(dir)) {
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(join(dir, name), 0o600)
|
|
72
|
+
} catch {
|
|
73
|
+
/* per-file best-effort */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* dir gone — nothing to harden */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Tail-keep cap over every command log under <eventLogDir>/tmux-cmdlog/<identity>.
|
|
82
|
+
* A file over capBytes is rewritten as its last keepBytes. Safe against the
|
|
83
|
+
* live writer: tmux logs with O_APPEND, so the server's next write lands at
|
|
84
|
+
* the new EOF (proven by live truncation test). Lines appended between the
|
|
85
|
+
* tail-read and the rewrite are lost — acceptable mid-life noise; the death
|
|
86
|
+
* tail is never here because the peer is alive while being capped. */
|
|
87
|
+
export function capCmdLogs(
|
|
88
|
+
eventLogDir: string,
|
|
89
|
+
capBytes: number = CMDLOG_CAP_BYTES,
|
|
90
|
+
keepBytes: number = CMDLOG_KEEP_BYTES,
|
|
91
|
+
): string[] {
|
|
92
|
+
const capped: string[] = []
|
|
93
|
+
let identities: string[]
|
|
94
|
+
try {
|
|
95
|
+
identities = readdirSync(join(eventLogDir, CMDLOG_SUBDIR))
|
|
96
|
+
} catch {
|
|
97
|
+
return capped
|
|
98
|
+
}
|
|
99
|
+
for (const identity of identities) {
|
|
100
|
+
const dir = join(eventLogDir, CMDLOG_SUBDIR, identity)
|
|
101
|
+
let files: string[]
|
|
102
|
+
try {
|
|
103
|
+
files = readdirSync(dir)
|
|
104
|
+
} catch {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
for (const name of files) {
|
|
108
|
+
if (!name.endsWith('.log')) continue
|
|
109
|
+
const path = join(dir, name)
|
|
110
|
+
try {
|
|
111
|
+
if (statSync(path).size <= capBytes) continue
|
|
112
|
+
const fd = openSync(path, 'r')
|
|
113
|
+
let tail: Buffer
|
|
114
|
+
try {
|
|
115
|
+
const size = fstatSync(fd).size
|
|
116
|
+
const start = Math.max(0, size - keepBytes)
|
|
117
|
+
tail = Buffer.alloc(Math.min(keepBytes, size))
|
|
118
|
+
readSync(fd, tail, 0, tail.length, start)
|
|
119
|
+
} finally {
|
|
120
|
+
closeSync(fd)
|
|
121
|
+
}
|
|
122
|
+
writeFileSync(path, tail, { mode: 0o600 })
|
|
123
|
+
capped.push(path)
|
|
124
|
+
} catch {
|
|
125
|
+
/* per-file best-effort */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return capped
|
|
130
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { spawnSync } from 'child_process'
|
|
|
20
20
|
import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
|
|
21
21
|
import { readLaunchEnv } from '../storage/index.ts'
|
|
22
22
|
import { canaryChannel, dismissCanary, ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
|
|
23
|
+
import { hardenCmdLogDir, prepareCmdLogDir } from './cmdlog.ts'
|
|
23
24
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
24
25
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
25
26
|
import { codexAdapter } from './adapters/codex.ts'
|
|
@@ -262,14 +263,31 @@ export const launch: LaunchFn = async (
|
|
|
262
263
|
PEER_IDENTITY: identity,
|
|
263
264
|
PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
|
|
264
265
|
}
|
|
266
|
+
// (2.4) tmux command-log (killer-hunt socket-command layer, cmdlog.ts): WARM
|
|
267
|
+
// agent sessions start their server with `-v` so every client command —
|
|
268
|
+
// incl. a killer's `kill-server`/`kill-session`, invisible to signal
|
|
269
|
+
// forensics — lands in tmux-server-<pid>.log with client attribution
|
|
270
|
+
// (`message: client-<pid> command: …`). The starting client runs FROM the
|
|
271
|
+
// per-identity 0700 cmdlog dir (that is where tmux drops the logs; the
|
|
272
|
+
// pane's cwd is still `-c cwd`). Gated like the other observability layers
|
|
273
|
+
// on cfg.exitLogDir; skipped for always-on infra (rare relaunch = no
|
|
274
|
+
// rotation point, and not the dying class). Best-effort: a failed dir
|
|
275
|
+
// prepare just launches without -v.
|
|
276
|
+
const cmdLogDir = !cfg.alwaysOn && cfg.exitLogDir ? prepareCmdLogDir(cfg.exitLogDir, identity) : undefined
|
|
265
277
|
const start = spawnSync(
|
|
266
278
|
'tmux',
|
|
267
|
-
[
|
|
268
|
-
|
|
279
|
+
[
|
|
280
|
+
'-S', sock,
|
|
281
|
+
...(cmdLogDir ? ['-v'] : []),
|
|
282
|
+
'new-session', '-d', '-s', identity, '-x', '220', '-y', '50', '-c', cwd, runtimeCmd,
|
|
283
|
+
],
|
|
284
|
+
{ env: childEnv as Record<string, string>, encoding: 'utf8', ...(cmdLogDir ? { cwd: cmdLogDir } : {}) },
|
|
269
285
|
)
|
|
270
286
|
if (start.status !== 0) {
|
|
271
287
|
return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
|
|
272
288
|
}
|
|
289
|
+
// The server/client logs exist once new-session returns — clamp them to 0600.
|
|
290
|
+
if (cmdLogDir) hardenCmdLogDir(cmdLogDir)
|
|
273
291
|
|
|
274
292
|
// (2.5) Exit-cause observability: a `pane-died` hook that records WHY this
|
|
275
293
|
// session's process dies (status/signal) at the moment of death into
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
|
+
import { capCmdLogs } from '../launch/cmdlog.ts'
|
|
29
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
30
31
|
import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
|
|
31
32
|
import {
|
|
@@ -1071,6 +1072,11 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1071
1072
|
const env = deps.env ?? process.env
|
|
1072
1073
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1073
1074
|
const verbose = superviseLogVerbose(env)
|
|
1075
|
+
// tmux command-log volume cap (killer-hunt socket-command layer, launch/cmdlog.ts):
|
|
1076
|
+
// a -v server log under a busy TUI grows ~7 KB/s, so each tick tail-keeps any log
|
|
1077
|
+
// over the cap. Best-effort by construction; the killer's command is the LAST
|
|
1078
|
+
// line of a dying log, so capping a LIVE peer's log never costs the death tail.
|
|
1079
|
+
capCmdLogs(cfg.eventLogDir)
|
|
1074
1080
|
// Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
|
|
1075
1081
|
// so a postmortem can answer "when & how did peer X's prior session end" even
|
|
1076
1082
|
// after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd
|