@sickr/cli 0.9.5 → 0.9.7
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/dist/cli.js +122 -94
- package/dist/run.js +174 -11
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -51,103 +51,72 @@ export function buildWorkflowInvocation(rest, command = 'uvx') {
|
|
|
51
51
|
}
|
|
52
52
|
return { command, args: rest };
|
|
53
53
|
}
|
|
54
|
-
export const HELP = `
|
|
54
|
+
export const HELP = `sickr — record, replay, and remote-control your AI coding agents.
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
redacted timeline you can replay — and optionally share as a public link.
|
|
56
|
+
Usage: npx @sickr/cli <command> [options]
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
REPLAY (free) — local recording of every prompt, edit and command.
|
|
59
|
+
replay Install Claude + Codex recording hooks. Use the agents
|
|
60
|
+
as normal — a redacted timeline is captured to
|
|
61
|
+
~/.sickr/runs.
|
|
62
|
+
replay open [id] Render the newest run (or a specific id) as a local
|
|
63
|
+
HTML timeline. Combine across agents with --today,
|
|
64
|
+
--since <2h|30m|1d>, or --all (+ --claude / --codex).
|
|
65
|
+
replay share [id] Publish a redacted run to sickr.ai/r/<id> (asks first).
|
|
66
|
+
Add --yes to skip the prompt; --open to open after.
|
|
67
|
+
Or combine a window: --today / --since <dur> / --all.
|
|
68
|
+
Anon links live 24h; signed-in links live 7 days.
|
|
69
|
+
replay list List recorded runs, newest first (+ --claude/--codex).
|
|
70
|
+
replay stop Remove sickr hooks from this project (keeps runs).
|
|
71
|
+
replay clear Delete all local runs in ~/.sickr/runs (asks first).
|
|
62
72
|
|
|
63
|
-
|
|
73
|
+
LIVE LOOK (Replay Pro) — passive streaming to a watching browser.
|
|
74
|
+
live Stream the current session to sickr.ai/r/<your-link> in
|
|
75
|
+
real time. Browser steer messages land in
|
|
76
|
+
~/.sickr/inbox/<urlid>.md and your terminal prints them.
|
|
77
|
+
Requires \`sickr login\` + Replay Pro entitlement.
|
|
78
|
+
live status Show pid + connection state.
|
|
79
|
+
live stop Stop the sidecar.
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
REMOTE CONTROL (Replay Pro) — browser keystrokes drive the agent.
|
|
82
|
+
run <agent> Wrap an agent in a PTY sickr owns; browser steer
|
|
83
|
+
messages are written directly into the agent's stdin.
|
|
84
|
+
Real remote control, not pasteboard.
|
|
85
|
+
sickr run claude Claude Code under sickr
|
|
86
|
+
sickr run codex Codex under sickr
|
|
87
|
+
sickr run <bin> arbitrary binary
|
|
88
|
+
Flags:
|
|
89
|
+
--mode auto (default) inject the agent's
|
|
90
|
+
"no prompt / full perms" flag so it
|
|
91
|
+
doesn't stall on tool confirms the
|
|
92
|
+
operator can't see.
|
|
93
|
+
--mode interactive pass agent flags through
|
|
94
|
+
verbatim — agent prompts for tool use.
|
|
95
|
+
Requires \`sickr login\` + Replay Pro entitlement.
|
|
96
|
+
Codex 0.133+ needs \`/hooks\` typed once to trust the
|
|
97
|
+
recorder; Claude is silent (hooks auto-installed).
|
|
80
98
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
all both of the above (feeds the combined view)
|
|
87
|
-
Flag: --no-name (label prompts "Human", not your login name)
|
|
88
|
-
open [run] Render a run to a local HTML timeline and open it. 100% local.
|
|
89
|
-
Defaults to the newest run; pass a run id, or --codex/--claude
|
|
90
|
-
for the newest run of that agent. Combine across agents with a
|
|
91
|
-
window: --today, --since <2h|30m|1d>, or --all (interleaved,
|
|
92
|
-
filterable by agent, sortable by prompt/response time).
|
|
93
|
-
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
94
|
-
(shows a preview and asks first). Links expire after 24h.
|
|
95
|
-
--open also open the published link in your browser
|
|
96
|
-
--yes skip the confirmation prompt
|
|
97
|
-
Or publish a COMBINED multi-agent view with a window:
|
|
98
|
-
--today / --since <2h|30m|1d> / --all (+ --claude/--codex).
|
|
99
|
-
list List recorded runs, newest first.
|
|
100
|
-
stop Stop recording — removes SICKR's hooks from this project.
|
|
101
|
-
Your recorded runs are kept; run \`init\` to start again.
|
|
102
|
-
clear Delete all local runs in ~/.sickr/runs (asks first).
|
|
103
|
-
login Sign in with GitHub (optional — unlocks persistent shares and
|
|
104
|
-
Replay Pro cohort eligibility). Zero-account use still works.
|
|
105
|
-
logout Forget the local login. Server-side session stays valid until
|
|
106
|
-
it expires; revoke from your account page if needed.
|
|
107
|
-
whoami Show who you're logged in as.
|
|
108
|
-
live Replay Pro: stream the current session to sickr.ai/r/<your-link>
|
|
109
|
-
in real time. Requires \`login\` and Replay Pro entitlement.
|
|
110
|
-
replay live start the sidecar (foreground; ^C exits)
|
|
111
|
-
replay live status show pid + connection state
|
|
112
|
-
replay live stop stop the sidecar
|
|
113
|
-
While running, steer messages from the watching browser are
|
|
114
|
-
saved to ~/.sickr/inbox/<urlid>.md and printed in your terminal.
|
|
115
|
-
run <agent> Replay Pro (Phase 2): wrap an agent in a PTY this CLI owns.
|
|
116
|
-
Browser steer messages land directly in the agent's stdin —
|
|
117
|
-
real remote control, no copy-paste from inbox.
|
|
118
|
-
sickr run claude spawn Claude Code under sickr
|
|
119
|
-
sickr run codex same for Codex
|
|
120
|
-
sickr run --cmd <bin> arbitrary command
|
|
121
|
-
\`node-pty\` ships as an optional dep + auto-installs with the
|
|
122
|
-
CLI on supported platforms (mac, Linux, Windows 10+).
|
|
123
|
-
Browsers can override per-message: {mode:'queue'|'steer',
|
|
124
|
-
submit:true|false}; default is auto-steer with submit.
|
|
125
|
-
agent connect --agent-id <id>
|
|
126
|
-
Connect this machine to a configured SICKR agent using GitHub
|
|
127
|
-
browser approval. Stores the agent key in ~/.sickr/agent.json.
|
|
128
|
-
agent status Show the connected agent, org and team.
|
|
129
|
-
agent rotate Rotate this machine's agent key.
|
|
130
|
-
agent disconnect
|
|
131
|
-
Revoke this machine's agent key and remove it locally.
|
|
132
|
-
help Show this help.
|
|
99
|
+
ACCOUNT
|
|
100
|
+
login Sign in with GitHub. Unlocks persistent shares
|
|
101
|
+
(24h → 7d) and Replay Pro entitlement.
|
|
102
|
+
logout Forget the local login.
|
|
103
|
+
whoami Show who you're signed in as.
|
|
133
104
|
|
|
134
|
-
|
|
135
|
-
|
|
105
|
+
WORKFLOW (sickr-managed agent + ticketing)
|
|
106
|
+
workflow connect --agent-id <id> Approve this machine for a configured
|
|
107
|
+
workflow agent (GitHub browser flow).
|
|
108
|
+
workflow start --agent-id <id> Start the orchestrator for an agent.
|
|
109
|
+
workflow status Show running daemon status.
|
|
110
|
+
workflow rotate | disconnect Rotate or revoke this machine's key.
|
|
136
111
|
|
|
137
|
-
|
|
138
|
-
This replays your AI coding agents on ONE machine. SICKR governs your whole team.
|
|
139
|
-
Issue tracking + your team + automation + agents — one governed workflow for
|
|
140
|
-
audit, accountability, productivity and confidence.
|
|
112
|
+
help Show this help.
|
|
141
113
|
|
|
142
|
-
|
|
143
|
-
validation checks until each one passes.
|
|
144
|
-
· Humans + agents on one board — agents are first-class teammates with
|
|
145
|
-
roles, capacity and accountability, not a side channel.
|
|
146
|
-
· A full, signed-off audit trail across every actor and every change.
|
|
147
|
-
· Runs 24/7 — produce as much work as you like; the team handles it.
|
|
114
|
+
Requires Node 20+. Codex capture needs Codex CLI 0.133+.
|
|
148
115
|
|
|
149
|
-
|
|
150
|
-
|
|
116
|
+
────────────────────────────────────────────────────────────────────
|
|
117
|
+
sickr also governs your whole team — issue tracking + humans + agents on one
|
|
118
|
+
board, gates & approvals, signed audit trail, runs 24/7. Free tier available;
|
|
119
|
+
bring your own Claude or Codex subscription. → https://sickr.ai
|
|
151
120
|
`;
|
|
152
121
|
export function currentRunId(cc) {
|
|
153
122
|
return String(cc.session_id ?? 'session');
|
|
@@ -850,6 +819,24 @@ async function fetchReplayProEntitlement() {
|
|
|
850
819
|
return false;
|
|
851
820
|
}
|
|
852
821
|
}
|
|
822
|
+
/** Gate a Pro-only command. Returns true if the user is allowed; otherwise
|
|
823
|
+
* prints a friendly explanation and returns false. Distinguishes the three
|
|
824
|
+
* failure modes (not-logged-in / logged-in-no-pro / network-fail) so the
|
|
825
|
+
* operator knows exactly what to do next. */
|
|
826
|
+
async function requireReplayPro(commandLabel) {
|
|
827
|
+
const creds = readCredentials();
|
|
828
|
+
if (!creds) {
|
|
829
|
+
process.stderr.write(`sickr: \`${commandLabel}\` is a Replay Pro feature.\n` +
|
|
830
|
+
` run \`sickr login\` first — your Pro entitlement is attached to your GitHub account.\n`);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
if (await fetchReplayProEntitlement())
|
|
834
|
+
return true;
|
|
835
|
+
process.stderr.write(`sickr: \`${commandLabel}\` is a Replay Pro feature.\n` +
|
|
836
|
+
` you are signed in as ${creds.login}, but your account isn't on Replay Pro.\n` +
|
|
837
|
+
` join the rolling-cohort waitlist → https://sickr.ai/#waitlist\n`);
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
853
840
|
async function handleReplay(rest) {
|
|
854
841
|
const sub = replaySubcommand(rest);
|
|
855
842
|
if (!sub) {
|
|
@@ -923,6 +910,10 @@ async function handleReplay(rest) {
|
|
|
923
910
|
liveStatus();
|
|
924
911
|
return;
|
|
925
912
|
}
|
|
913
|
+
if (!(await requireReplayPro('sickr replay live'))) {
|
|
914
|
+
process.exit(3);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
926
917
|
await startLive({ verbose: replayRest.includes('--verbose') || replayRest.includes('-v'), background: replayRest.includes('--background') });
|
|
927
918
|
return;
|
|
928
919
|
}
|
|
@@ -1122,28 +1113,65 @@ async function main() {
|
|
|
1122
1113
|
liveStatus();
|
|
1123
1114
|
return;
|
|
1124
1115
|
}
|
|
1116
|
+
if (!(await requireReplayPro('sickr live'))) {
|
|
1117
|
+
process.exit(3);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1125
1120
|
// default: start (foreground)
|
|
1126
1121
|
const opts = { verbose: rest.includes('--verbose') || rest.includes('-v'), background: rest.includes('--background') };
|
|
1127
1122
|
await startLive(opts);
|
|
1128
1123
|
return;
|
|
1129
1124
|
}
|
|
1130
1125
|
case 'run': {
|
|
1131
|
-
// sickr run <agent> [...agent-args] [--verbose]
|
|
1126
|
+
// sickr run <agent> [--mode auto|interactive] [...agent-args] [--verbose]
|
|
1132
1127
|
// Wraps the agent in a PTY the CLI owns. Browser steers go
|
|
1133
1128
|
// straight to stdin. node-pty is loaded on first use.
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1129
|
+
// --mode is stripped before forwarding; the rest passes through verbatim.
|
|
1130
|
+
const filtered = [];
|
|
1131
|
+
let mode = 'auto';
|
|
1132
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1133
|
+
if (rest[i] === '--mode' && rest[i + 1]) {
|
|
1134
|
+
const v = rest[i + 1];
|
|
1135
|
+
if (v !== 'auto' && v !== 'interactive') {
|
|
1136
|
+
process.stderr.write(`sickr: --mode must be 'auto' or 'interactive' (got '${v}')\n`);
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
mode = v;
|
|
1141
|
+
i++;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
if (rest[i].startsWith('--mode=')) {
|
|
1145
|
+
const v = rest[i].slice('--mode='.length);
|
|
1146
|
+
if (v !== 'auto' && v !== 'interactive') {
|
|
1147
|
+
process.stderr.write(`sickr: --mode must be 'auto' or 'interactive' (got '${v}')\n`);
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
mode = v;
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
filtered.push(rest[i]);
|
|
1155
|
+
}
|
|
1156
|
+
const positional = filtered.filter((a) => !a.startsWith('-'));
|
|
1157
|
+
const flags = filtered.filter((a) => a.startsWith('-'));
|
|
1136
1158
|
const agent = positional[0];
|
|
1137
1159
|
if (!agent) {
|
|
1138
|
-
process.stderr.write('sickr: usage — `sickr run <agent> [args...]` (agent: claude | codex | <bin name>)\n');
|
|
1160
|
+
process.stderr.write('sickr: usage — `sickr run <agent> [--mode auto|interactive] [args...]` (agent: claude | codex | <bin name>)\n');
|
|
1139
1161
|
process.exit(1);
|
|
1140
1162
|
return;
|
|
1141
1163
|
}
|
|
1142
|
-
|
|
1164
|
+
// Pass through every non-sickr flag plus positional tail to the agent.
|
|
1165
|
+
const passthroughFlags = flags.filter((f) => f !== '--verbose' && f !== '-v');
|
|
1166
|
+
const agentArgs = [...passthroughFlags, ...positional.slice(1)];
|
|
1143
1167
|
const verbose = flags.includes('--verbose') || flags.includes('-v');
|
|
1168
|
+
if (!(await requireReplayPro('sickr run'))) {
|
|
1169
|
+
process.exit(3);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1144
1172
|
const { startRun } = await import('./run.js');
|
|
1145
1173
|
try {
|
|
1146
|
-
await startRun({ agent, agentArgs, verbose });
|
|
1174
|
+
await startRun({ agent, agentArgs, verbose, mode });
|
|
1147
1175
|
}
|
|
1148
1176
|
catch (e) {
|
|
1149
1177
|
process.stderr.write(`sickr: ${e.message}\n`);
|
package/dist/run.js
CHANGED
|
@@ -23,16 +23,62 @@
|
|
|
23
23
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
26
27
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
27
28
|
import { readCredentials } from './auth.js';
|
|
28
29
|
import { runsDir } from './recorder.js';
|
|
30
|
+
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
29
31
|
import { LIVE_BASE, decodeWsPayload, splitJsonObjects } from './live.js';
|
|
30
|
-
/** Resolve the agent binary path.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
32
|
+
/** Resolve the agent binary path. Precedence:
|
|
33
|
+
* 1. SICKR_AGENT_BIN_<NAME> env (e.g. SICKR_AGENT_BIN_CLAUDE)
|
|
34
|
+
* 2. <NAME>_BIN env (e.g. CLAUDE_BIN)
|
|
35
|
+
* 3. OS shell lookup (`where` on Windows, `which` on Unix) so PATHEXT
|
|
36
|
+
* resolution finds `claude.cmd`, `claude.exe`, etc.
|
|
37
|
+
* 4. Bare name (node-pty handles it; rarely works on Windows).
|
|
38
|
+
*
|
|
39
|
+
* Windows-specific: node-pty's CreateProcess does NOT respect PATHEXT,
|
|
40
|
+
* so a bare `claude` will fail with 'Cannot create process, error code: 2'
|
|
41
|
+
* even when `claude.cmd` is on PATH. The shell lookup at step 3 closes
|
|
42
|
+
* the gap. Surfaced 2026-06-01 on the first Windows E2E test. */
|
|
43
|
+
export function resolveAgent(agent) {
|
|
44
|
+
if (/[\/\\]/.test(agent))
|
|
45
|
+
return agent; // absolute / relative path used as-is
|
|
34
46
|
const envOverride = process.env[`SICKR_AGENT_BIN_${agent.toUpperCase()}`] ?? process.env[`${agent.toUpperCase()}_BIN`];
|
|
35
|
-
|
|
47
|
+
if (envOverride)
|
|
48
|
+
return envOverride;
|
|
49
|
+
// Shell lookup. Cheap (~20ms) and only fires once per `sickr run`.
|
|
50
|
+
// Wrapped in try/catch so a missing `where`/`which` doesn't crash —
|
|
51
|
+
// we fall through to the bare name, which gives the original error
|
|
52
|
+
// and lets the user set SICKR_AGENT_BIN_<NAME> as escape hatch.
|
|
53
|
+
try {
|
|
54
|
+
const isWin = process.platform === 'win32';
|
|
55
|
+
const cmd = isWin ? 'where' : 'which';
|
|
56
|
+
const out = execFileSync(cmd, [agent], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
57
|
+
const candidates = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
58
|
+
if (candidates.length === 0)
|
|
59
|
+
return agent;
|
|
60
|
+
// On Windows, `where` returns multiple candidates when both the npm
|
|
61
|
+
// shell-script wrapper (no extension) and the .cmd/.bat shim exist.
|
|
62
|
+
// CreateProcess can't run the unextensioned wrapper, so prefer
|
|
63
|
+
// executable extensions in this order: .exe, .cmd, .bat, .com,
|
|
64
|
+
// then anything else. Bug surfaced 2026-06-01 — `where claude` led
|
|
65
|
+
// with `C:\...\nvm\v22\claude` (no ext) before `claude.cmd`.
|
|
66
|
+
if (isWin) {
|
|
67
|
+
const EXEC_RANK = ['.exe', '.cmd', '.bat', '.com'];
|
|
68
|
+
const sorted = candidates.slice().sort((a, b) => {
|
|
69
|
+
const ar = EXEC_RANK.findIndex((ext) => a.toLowerCase().endsWith(ext));
|
|
70
|
+
const br = EXEC_RANK.findIndex((ext) => b.toLowerCase().endsWith(ext));
|
|
71
|
+
// Items with a recognised exec ext sort first; ties keep original order.
|
|
72
|
+
const ai = ar === -1 ? EXEC_RANK.length : ar;
|
|
73
|
+
const bi = br === -1 ? EXEC_RANK.length : br;
|
|
74
|
+
return ai - bi;
|
|
75
|
+
});
|
|
76
|
+
return sorted[0];
|
|
77
|
+
}
|
|
78
|
+
return candidates[0];
|
|
79
|
+
}
|
|
80
|
+
catch { /* not found; fall through */ }
|
|
81
|
+
return agent;
|
|
36
82
|
}
|
|
37
83
|
/** UTC date YYYY-MM-DD — matches sickr-ui's /api/replay slot id formula. */
|
|
38
84
|
function utcDate(now = new Date()) { return now.toISOString().slice(0, 10); }
|
|
@@ -72,9 +118,17 @@ export function decideSteer(msg, defaultMode = 'pty') {
|
|
|
72
118
|
const mode = msg.mode === 'queue' ? 'inbox' : msg.mode === 'steer' ? 'pty' : defaultMode;
|
|
73
119
|
if (mode === 'inbox')
|
|
74
120
|
return { target: 'inbox' };
|
|
75
|
-
// For PTY: append
|
|
76
|
-
// because the whole point of `sickr run` is
|
|
77
|
-
// requires saying so.
|
|
121
|
+
// For PTY: append a line terminator unless submit was explicitly
|
|
122
|
+
// false. Default true because the whole point of `sickr run` is
|
|
123
|
+
// auto-submit; opt out requires saying so.
|
|
124
|
+
//
|
|
125
|
+
// We send \r (CR only). That's what the Enter key produces in most
|
|
126
|
+
// TUIs running under a PTY in raw mode (the terminal driver doesn't
|
|
127
|
+
// translate CR -> LF for raw input). Surfaced 2026-06-01: bare \r
|
|
128
|
+
// worked for echo / cat tests but Claude Code's React Ink input
|
|
129
|
+
// handler appeared to ignore single \r on Windows ConPTY. Sending
|
|
130
|
+
// \r\n is widely compatible: bare-\r TUIs treat the trailing \n as
|
|
131
|
+
// an empty no-op line; line-buffered programs see a single line.
|
|
78
132
|
const submit = msg.submit !== false;
|
|
79
133
|
return { target: 'pty', bytes: submit ? text + '\r' : text };
|
|
80
134
|
}
|
|
@@ -113,6 +167,54 @@ function tailFrom(path, from) {
|
|
|
113
167
|
closeSync(fd);
|
|
114
168
|
}
|
|
115
169
|
}
|
|
170
|
+
/** Silently ensure recording hooks are installed for the wrapped agent
|
|
171
|
+
* in the CURRENT working directory. Without this, Claude / Codex won't
|
|
172
|
+
* emit events to ~/.sickr/runs/, so the WS push has nothing to forward
|
|
173
|
+
* to the DO and the browser /r/<urlid> view stays empty.
|
|
174
|
+
*
|
|
175
|
+
* Surfaced 2026-06-01: first E2E spawned Claude under PTY but refresh
|
|
176
|
+
* on the browser showed only an unrelated event from a prior session.
|
|
177
|
+
* Operator hit the "have to remember `sickr init claude` first" trap.
|
|
178
|
+
*
|
|
179
|
+
* This function is idempotent — if the file already has the right
|
|
180
|
+
* hooks the merge is a no-op. We don't print anything on success
|
|
181
|
+
* (the spawn is about to take over the terminal). Returns true if
|
|
182
|
+
* hooks were ensured / installed; false if the agent name isn't
|
|
183
|
+
* one we know how to init for. */
|
|
184
|
+
export function ensureRecordingHooks(agent) {
|
|
185
|
+
const provider = agent === 'claude' || agent === 'codex' ? agent : null;
|
|
186
|
+
if (!provider)
|
|
187
|
+
return false; // Custom binaries — caller decides whether to install hooks
|
|
188
|
+
const settingsPath = provider === 'codex'
|
|
189
|
+
? join(process.cwd(), '.codex', 'hooks.json')
|
|
190
|
+
: join(process.cwd(), '.claude', 'settings.json');
|
|
191
|
+
let existing = {};
|
|
192
|
+
if (existsSync(settingsPath)) {
|
|
193
|
+
try {
|
|
194
|
+
existing = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
existing = {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Cheap detection — if the file already mentions our record command,
|
|
201
|
+
// skip the write. Avoids touching a file every run when the user
|
|
202
|
+
// already init'd here.
|
|
203
|
+
const serialized = JSON.stringify(existing);
|
|
204
|
+
if (serialized.includes('@sickr/cli record') || serialized.includes('@sickr/replay record')) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// Merge using the same logic as cli.ts::handleInit but silent.
|
|
208
|
+
// Dynamic import keeps the file from pulling hookConfig at module
|
|
209
|
+
// load (tiny boot perf + lets tests run without the module).
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
211
|
+
const command = `npx @sickr/cli record${provider === 'codex' ? ' --codex' : ''}`;
|
|
212
|
+
const merged = mergeHooks(removeHooks(existing), command);
|
|
213
|
+
mkdirSync(join(settingsPath, '..'), { recursive: true });
|
|
214
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
215
|
+
mkdirSync(runsDir(), { recursive: true });
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
116
218
|
/** Best-effort load of node-pty. Returns the spawn function or throws
|
|
117
219
|
* with a copy-pasteable install hint. */
|
|
118
220
|
async function loadNodePtySpawn() {
|
|
@@ -134,6 +236,30 @@ async function loadNodePtySpawn() {
|
|
|
134
236
|
`Underlying error: ${e.message ?? 'unknown'}`);
|
|
135
237
|
}
|
|
136
238
|
}
|
|
239
|
+
/** Per-agent flag that disables prompting / sandboxing so the wrapped
|
|
240
|
+
* agent doesn't stall waiting for unattended confirmation. Pure function
|
|
241
|
+
* — exported for unit tests. Honors operator overrides: if the operator
|
|
242
|
+
* already passed the relevant flag (or any opposite), we don't double-add.
|
|
243
|
+
*
|
|
244
|
+
* History (2026-06-01): `sickr run claude` defaulted to interactive and
|
|
245
|
+
* surfaced as a "hung" terminal — Claude was waiting on a tool-use
|
|
246
|
+
* permission prompt the operator couldn't see in the PTY mirror. Moving
|
|
247
|
+
* the default to "auto / full permissions" closes the gap; `--mode
|
|
248
|
+
* interactive` opts back in for sensitive sessions. */
|
|
249
|
+
export function autoModeArgsFor(agent, existingArgs) {
|
|
250
|
+
const a = agent.toLowerCase();
|
|
251
|
+
const has = (needle) => existingArgs.some((x) => x === needle || x.startsWith(needle + '='));
|
|
252
|
+
if (a === 'claude' || a.endsWith('/claude') || a.endsWith('\\claude') || a.endsWith('claude.cmd') || a.endsWith('claude.exe')) {
|
|
253
|
+
return has('--dangerously-skip-permissions') ? [] : ['--dangerously-skip-permissions'];
|
|
254
|
+
}
|
|
255
|
+
if (a === 'codex' || a.endsWith('/codex') || a.endsWith('\\codex') || a.endsWith('codex.cmd') || a.endsWith('codex.exe')) {
|
|
256
|
+
if (has('--dangerously-bypass-approvals-and-sandbox') || has('--full-auto') || has('--ask-for-approval'))
|
|
257
|
+
return [];
|
|
258
|
+
return ['--dangerously-bypass-approvals-and-sandbox'];
|
|
259
|
+
}
|
|
260
|
+
// Unknown agent — no opinion. Operator should pass their own flags.
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
137
263
|
export async function startRun(opts) {
|
|
138
264
|
const creds = readCredentials();
|
|
139
265
|
if (!creds) {
|
|
@@ -149,12 +275,33 @@ export async function startRun(opts) {
|
|
|
149
275
|
process.stderr.write(`sickr: couldn't resolve live url (${e.message}). Replay Pro required.\n`);
|
|
150
276
|
process.exit(4);
|
|
151
277
|
}
|
|
278
|
+
// Ensure recording hooks are installed in the cwd for known agents.
|
|
279
|
+
// Without this, Claude / Codex run normally but emit no events — the
|
|
280
|
+
// browser /r/<urlid> stays empty until events flow.
|
|
281
|
+
ensureRecordingHooks(opts.agent);
|
|
152
282
|
const agentBin = resolveAgent(opts.agent);
|
|
153
283
|
const cols = process.stdout.columns ?? 80;
|
|
154
284
|
const rows = process.stdout.rows ?? 24;
|
|
285
|
+
// mode=auto (default) injects the agent's "no prompt / full perms" flag
|
|
286
|
+
// so the wrapped CLI doesn't stall on tool-use confirmations the
|
|
287
|
+
// operator can't see. --mode interactive disables the injection.
|
|
288
|
+
const mode = opts.mode ?? 'auto';
|
|
289
|
+
const injected = mode === 'auto' ? autoModeArgsFor(opts.agent, opts.agentArgs) : [];
|
|
290
|
+
const effectiveArgs = [...injected, ...opts.agentArgs];
|
|
155
291
|
process.stdout.write(`sickr: wrapping ${agentBin} in PTY. Watch + steer at: https://sickr.ai/r/${urlid}\n`);
|
|
292
|
+
if (mode === 'auto' && injected.length > 0) {
|
|
293
|
+
process.stdout.write(` mode=auto — injected ${injected.join(' ')} (use --mode interactive to disable)\n`);
|
|
294
|
+
}
|
|
295
|
+
else if (mode === 'interactive') {
|
|
296
|
+
process.stdout.write(` mode=interactive — agent will prompt for tool use; you confirm in the PTY\n`);
|
|
297
|
+
}
|
|
298
|
+
if (opts.agent === 'codex') {
|
|
299
|
+
// Codex 0.133+ gates new hooks until trusted; without trust the recorder
|
|
300
|
+
// is dormant and /r/<urlid> stays empty even though the PTY is live.
|
|
301
|
+
process.stdout.write(` codex hooks: type \`/hooks\` once inside codex to trust the recorder.\n`);
|
|
302
|
+
}
|
|
156
303
|
process.stdout.write(` browser steer messages land directly in your agent. ^C exits.\n\n`);
|
|
157
|
-
const pty = ptySpawn(agentBin,
|
|
304
|
+
const pty = ptySpawn(agentBin, effectiveArgs, {
|
|
158
305
|
name: 'xterm-256color',
|
|
159
306
|
cols, rows,
|
|
160
307
|
cwd: process.cwd(),
|
|
@@ -246,12 +393,28 @@ export async function startRun(opts) {
|
|
|
246
393
|
if (m.kind === 'steer' && m.text) {
|
|
247
394
|
const decision = decideSteer(m);
|
|
248
395
|
if (decision.target === 'pty' && decision.bytes != null) {
|
|
396
|
+
// Submit-fix (2026-06-01): Claude Code's TUI treats a burst of
|
|
397
|
+
// characters arriving with no inter-keystroke gap as a paste
|
|
398
|
+
// — and within a paste, \r is an inline newline, NOT a submit.
|
|
399
|
+
// Text appeared in the input box but never got sent until the
|
|
400
|
+
// operator manually hit Enter. Split the write: body first,
|
|
401
|
+
// then a short delay, then a standalone \r so it lands as a
|
|
402
|
+
// discrete Enter keypress instead of "tail of a paste".
|
|
403
|
+
const bytes = decision.bytes;
|
|
404
|
+
const bodyOnly = bytes.endsWith('\r') ? bytes.slice(0, -1) : bytes;
|
|
405
|
+
const submit = bytes.endsWith('\r');
|
|
249
406
|
try {
|
|
250
|
-
|
|
407
|
+
if (bodyOnly.length > 0)
|
|
408
|
+
pty.write(bodyOnly);
|
|
409
|
+
if (submit)
|
|
410
|
+
setTimeout(() => { try {
|
|
411
|
+
pty.write('\r');
|
|
412
|
+
}
|
|
413
|
+
catch { /* pty exited */ } }, 80);
|
|
251
414
|
}
|
|
252
415
|
catch { /* PTY may have exited */ }
|
|
253
416
|
if (opts.verbose)
|
|
254
|
-
process.stderr.write(`sickr: pty steer ${
|
|
417
|
+
process.stderr.write(`sickr: pty steer ${bytes.length}b${submit ? ' (split for submit)' : ''}\n`);
|
|
255
418
|
}
|
|
256
419
|
else {
|
|
257
420
|
appendInbox(urlid, m.text, m.at ?? new Date().toISOString());
|
package/package.json
CHANGED