@sickr/cli 0.9.10 → 0.9.12

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 CHANGED
@@ -5,7 +5,7 @@ import { homedir, userInfo } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { spawn, spawnSync, execFileSync } from 'node:child_process';
7
7
  import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
8
- import { mergeHooks, removeHooks } from './hookConfig.js';
8
+ import { removeHooks, mergeHooksForEvents, removeHooksForEvents } from './hookConfig.js';
9
9
  import { renderRunHtml, renderCombinedHtml } from './render.js';
10
10
  import { buildSharePayload, buildCombinedPayload, publish, PublishError } from './share.js';
11
11
  import { AUTH_ENDPOINT, readCredentials, writeCredentials, clearCredentials, startDevice, pollDevice, sleep } from './auth.js';
@@ -69,8 +69,8 @@ function providerFromFlags(args) {
69
69
  return 'codex';
70
70
  if (args.includes('--gemini'))
71
71
  return 'gemini';
72
- if (args.includes('--cursor'))
73
- return 'cursor';
72
+ if (args.includes('--ollama'))
73
+ return 'ollama';
74
74
  if (args.includes('--local'))
75
75
  return 'local';
76
76
  return 'claude';
@@ -113,7 +113,7 @@ RUN ($12) - remote control from the browser.
113
113
  sickr run claude
114
114
  sickr run codex
115
115
  sickr run gemini
116
- sickr run cursor-agent
116
+ sickr run ollama --model llama3.2
117
117
  sickr run <bin>
118
118
  Flags: --mode auto (default) | --mode interactive
119
119
 
@@ -129,9 +129,9 @@ Product modes are ordered: Prime Workflow > Run > Live > Replay.
129
129
  Prime Workflow runs PRIME for agentic or hybrid teams: plan, review,
130
130
  implement, merge, evaluate, and ship under control.
131
131
 
132
- Requires Node 20+. Codex capture needs Codex CLI 0.133+ and one manual
133
- /hooks trust approval. Gemini/Cursor/local runners are adapter-backed and
134
- may have provider-specific setup prompts.
132
+ Requires Node 20+. Codex capture needs one manual /hooks trust approval
133
+ per stable hook command. Gemini uses its native hook events. Ollama is
134
+ the supported local LLM runtime; configure the model on workflow.sickr.ai.
135
135
 
136
136
  https://sickr.ai
137
137
  `;
@@ -172,9 +172,13 @@ export function handleRecord(input, provider = 'claude') {
172
172
  try {
173
173
  const cc = JSON.parse(input);
174
174
  appendEvent(currentRunId(cc), cc, { human: resolveName(), agent: PROVIDERS[provider].recordLabel });
175
+ if (provider === 'gemini')
176
+ process.stdout.write('{}\n');
175
177
  }
176
178
  catch {
177
179
  /* swallow: recording is best-effort and must not disrupt the session */
180
+ if (provider === 'gemini')
181
+ process.stdout.write('{}\n');
178
182
  }
179
183
  }
180
184
  export function handleInit(provider, noName = false) {
@@ -189,7 +193,8 @@ export function handleInit(provider, noName = false) {
189
193
  // Remove any prior SICKR hook first, then install the current command — so
190
194
  // re-running init (or a CLI upgrade that changes the command) self-heals
191
195
  // instead of leaving a stale hook. Scoped to this provider's file.
192
- const merged = mergeHooks(removeHooks(settings), command);
196
+ const events = p.hookEvents ?? ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
197
+ const merged = mergeHooksForEvents(removeHooksForEvents(settings, events), command, events);
193
198
  mkdirSync(dirname(settingsPath), { recursive: true });
194
199
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
195
200
  mkdirSync(runsDir(), { recursive: true });
@@ -199,7 +204,7 @@ export function handleInit(provider, noName = false) {
199
204
  writeFileSync(configPath(), JSON.stringify({ name }, null, 2) + '\n');
200
205
  const labelLine = `Your prompts will be labelled "${name}"${noName ? '' : ' — run `init --no-name` to anonymize'}.\n`;
201
206
  const nextSteps = p.requiresManualHookTrust
202
- ? `Next: in ${p.displayName}, trust the SICKR recorder when prompted (Codex uses \`/hooks\`),\nthen use the agent as normal and: npx @sickr/cli replay open\n`
207
+ ? `Next: in ${p.displayName}, trust the SICKR recorder once when prompted${provider === 'codex' ? ' (type `/hooks` in Codex)' : ''},\nthen use the agent as normal and: npx @sickr/cli replay open\n`
203
208
  : `Use ${p.displayName} as normal, then: npx @sickr/cli replay open\n`;
204
209
  process.stdout.write(`sickr: installed ${p.displayName} recording hooks in ${settingsPath}\n` +
205
210
  `Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
@@ -674,12 +679,61 @@ export function handleWhoami() {
674
679
  process.stdout.write(`sickr: ${c.login}${c.name ? ` (${c.name})` : ''} · since ${c.login_at}\n`);
675
680
  }
676
681
  function valueAt(rest, flag) {
682
+ const eq = rest.find((item) => item.startsWith(`${flag}=`));
683
+ if (eq)
684
+ return eq.slice(flag.length + 1) || null;
677
685
  const i = rest.indexOf(flag);
678
686
  return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('-') ? rest[i + 1] : null;
679
687
  }
680
688
  function isRecord(value) {
681
689
  return typeof value === 'object' && value !== null && !Array.isArray(value);
682
690
  }
691
+ function stringArray(value) {
692
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null;
693
+ }
694
+ function providerFromValue(value) {
695
+ if (value === 'claude' || value === 'codex' || value === 'gemini' || value === 'ollama' || value === 'local')
696
+ return value;
697
+ return null;
698
+ }
699
+ function productModeFromValue(value) {
700
+ if (value === 'prime_workflow' || value === 'run' || value === 'live' || value === 'replay')
701
+ return value;
702
+ if (value === 'workflow' || value === 'prime')
703
+ return 'prime_workflow';
704
+ return null;
705
+ }
706
+ export function configuredRunInvocationFromStatus(status) {
707
+ const root = isRecord(status) ? status : {};
708
+ const agent = isRecord(root.agent) ? root.agent : {};
709
+ const runtime = isRecord(agent.runtime_config) ? agent.runtime_config
710
+ : isRecord(agent.run_config) ? agent.run_config
711
+ : isRecord(root.runtime_config) ? root.runtime_config
712
+ : {};
713
+ const provider = providerFromValue(runtime.provider) ?? providerFromValue(agent.provider) ?? 'claude';
714
+ const productMode = productModeFromValue(runtime.product_mode) ?? productModeFromValue(runtime.mode) ?? productModeFromValue(agent.product_mode) ?? productModeFromValue(agent.mode) ?? 'run';
715
+ const command = typeof runtime.command === 'string' ? runtime.command
716
+ : typeof agent.command === 'string' ? agent.command
717
+ : PROVIDERS[provider].defaultCommand;
718
+ const configuredArgs = stringArray(runtime.args) ?? stringArray(agent.args) ?? [];
719
+ const model = typeof runtime.model === 'string' ? runtime.model
720
+ : typeof agent.model === 'string' ? agent.model
721
+ : null;
722
+ const agentArgs = (provider === 'ollama' || provider === 'local') && model && configuredArgs.length === 0
723
+ ? ['--model', model]
724
+ : configuredArgs;
725
+ const mode = runtime.pty_mode === 'interactive' || runtime.run_mode === 'interactive' || runtime.mode === 'interactive' || agent.pty_mode === 'interactive' ? 'interactive' : 'auto';
726
+ return { agent: command, agentArgs, mode, productMode };
727
+ }
728
+ export async function resolveConfiguredRun(agentId, credentials = readAgentCredentials()) {
729
+ if (!credentials) {
730
+ throw new Error('agent is not connected. Run `sickr prime connect --agent-id <id>` first.');
731
+ }
732
+ if (credentials.agent_id !== agentId) {
733
+ throw new Error(`local machine is connected as ${credentials.agent_id}, not ${agentId}. Run \`sickr prime connect --agent-id ${agentId}\` first.`);
734
+ }
735
+ return configuredRunInvocationFromStatus(await fetchAgentStatus(credentials));
736
+ }
683
737
  function agentContextLabel(context) {
684
738
  const root = isRecord(context) ? context : {};
685
739
  const agent = isRecord(root.agent) ? root.agent : {};
@@ -870,7 +924,7 @@ async function handleReplay(rest) {
870
924
  handleInit('codex', noName);
871
925
  return;
872
926
  }
873
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'cursor' || agent === 'local') {
927
+ if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'ollama' || agent === 'local') {
874
928
  handleInit(agent, noName);
875
929
  return;
876
930
  }
@@ -895,12 +949,12 @@ async function handleReplay(rest) {
895
949
  handleOpenCombined(sel);
896
950
  return;
897
951
  }
898
- const openProvider = replayRest.some((a) => ['--codex', '--claude', '--gemini', '--cursor', '--local'].includes(a)) ? providerFromFlags(replayRest) : undefined;
952
+ const openProvider = replayRest.some((a) => ['--codex', '--claude', '--gemini', '--ollama', '--local'].includes(a)) ? providerFromFlags(replayRest) : undefined;
899
953
  handleOpen(replayRest.find((a) => !a.startsWith('-')), openProvider);
900
954
  return;
901
955
  }
902
956
  if (sub === 'list') {
903
- const listProvider = replayRest.some((a) => ['--codex', '--claude', '--gemini', '--cursor', '--local'].includes(a)) ? providerFromFlags(replayRest) : undefined;
957
+ const listProvider = replayRest.some((a) => ['--codex', '--claude', '--gemini', '--ollama', '--local'].includes(a)) ? providerFromFlags(replayRest) : undefined;
904
958
  handleList(listProvider);
905
959
  return;
906
960
  }
@@ -996,6 +1050,56 @@ async function handlePrime(rest) {
996
1050
  }
997
1051
  runPrime(rest.length ? rest : ['status']);
998
1052
  }
1053
+ async function handleStart(rest) {
1054
+ const agentId = valueAt(rest, '--agent-id') ?? rest.find((arg) => !arg.startsWith('-')) ?? null;
1055
+ if (!agentId) {
1056
+ await handlePrime(['start', ...rest]);
1057
+ return;
1058
+ }
1059
+ let configured;
1060
+ try {
1061
+ configured = await resolveConfiguredRun(agentId);
1062
+ }
1063
+ catch (e) {
1064
+ process.stderr.write(`sickr: ${e.message}\n`);
1065
+ process.exit(2);
1066
+ return;
1067
+ }
1068
+ if (configured.productMode === 'prime_workflow') {
1069
+ await handlePrime(['start', ...rest]);
1070
+ return;
1071
+ }
1072
+ if (configured.productMode === 'live') {
1073
+ const { startLive } = await import('./live.js');
1074
+ if (!(await requireReplayPro('sickr start'))) {
1075
+ process.exit(3);
1076
+ return;
1077
+ }
1078
+ await startLive({ verbose: rest.includes('--verbose') || rest.includes('-v'), background: rest.includes('--background') });
1079
+ return;
1080
+ }
1081
+ if (configured.productMode === 'replay') {
1082
+ await handleReplay([]);
1083
+ return;
1084
+ }
1085
+ if (!(await requireReplayPro('sickr start'))) {
1086
+ process.exit(3);
1087
+ return;
1088
+ }
1089
+ const { startRun } = await import('./run.js');
1090
+ try {
1091
+ await startRun({
1092
+ agent: configured.agent,
1093
+ agentArgs: configured.agentArgs,
1094
+ verbose: rest.includes('--verbose') || rest.includes('-v'),
1095
+ mode: configured.mode,
1096
+ });
1097
+ }
1098
+ catch (e) {
1099
+ process.stderr.write(`sickr: ${e.message}\n`);
1100
+ process.exit(5);
1101
+ }
1102
+ }
999
1103
  export async function readStreamWithIdle(input, idleMs = 250, emptyMs = 1500) {
1000
1104
  const chunks = [];
1001
1105
  const iterator = input[Symbol.asyncIterator]();
@@ -1052,7 +1156,7 @@ async function main() {
1052
1156
  handleInit('codex', noName);
1053
1157
  return;
1054
1158
  }
1055
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'cursor' || agent === 'local') {
1159
+ if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'ollama' || agent === 'local') {
1056
1160
  handleInit(agent, noName);
1057
1161
  return;
1058
1162
  }
@@ -1066,12 +1170,12 @@ async function main() {
1066
1170
  handleOpenCombined(sel);
1067
1171
  return;
1068
1172
  }
1069
- const openProvider = rest.some((a) => ['--codex', '--claude', '--gemini', '--cursor', '--local'].includes(a)) ? providerFromFlags(rest) : undefined;
1173
+ const openProvider = rest.some((a) => ['--codex', '--claude', '--gemini', '--ollama', '--local'].includes(a)) ? providerFromFlags(rest) : undefined;
1070
1174
  handleOpen(rest.find((a) => !a.startsWith('-')), openProvider);
1071
1175
  return;
1072
1176
  }
1073
1177
  case 'list': {
1074
- const listProvider = rest.some((a) => ['--codex', '--claude', '--gemini', '--cursor', '--local'].includes(a)) ? providerFromFlags(rest) : undefined;
1178
+ const listProvider = rest.some((a) => ['--codex', '--claude', '--gemini', '--ollama', '--local'].includes(a)) ? providerFromFlags(rest) : undefined;
1075
1179
  handleList(listProvider);
1076
1180
  return;
1077
1181
  }
@@ -1100,7 +1204,7 @@ async function main() {
1100
1204
  await handlePrime(rest);
1101
1205
  return;
1102
1206
  case 'start':
1103
- await handlePrime(['start', ...rest]);
1207
+ await handleStart(rest);
1104
1208
  return;
1105
1209
  case 'status':
1106
1210
  await handlePrime(['status', ...rest]);
@@ -1156,6 +1260,13 @@ async function main() {
1156
1260
  const filtered = [];
1157
1261
  let mode = 'auto';
1158
1262
  for (let i = 0; i < rest.length; i++) {
1263
+ if (rest[i] === '--agent-id' && rest[i + 1]) {
1264
+ i++;
1265
+ continue;
1266
+ }
1267
+ if (rest[i].startsWith('--agent-id=')) {
1268
+ continue;
1269
+ }
1159
1270
  if (rest[i] === '--mode' && rest[i + 1]) {
1160
1271
  const v = rest[i + 1];
1161
1272
  if (v !== 'auto' && v !== 'interactive') {
@@ -1181,15 +1292,30 @@ async function main() {
1181
1292
  }
1182
1293
  const positional = filtered.filter((a) => !a.startsWith('-'));
1183
1294
  const flags = filtered.filter((a) => a.startsWith('-'));
1184
- const agent = positional[0];
1295
+ const configuredAgentId = valueAt(rest, '--agent-id');
1296
+ let agent = positional[0];
1297
+ let configuredArgs = null;
1298
+ if (!agent && configuredAgentId) {
1299
+ try {
1300
+ const configured = await resolveConfiguredRun(configuredAgentId);
1301
+ agent = configured.agent;
1302
+ configuredArgs = configured.agentArgs;
1303
+ mode = configured.mode;
1304
+ }
1305
+ catch (e) {
1306
+ process.stderr.write(`sickr: ${e.message}\n`);
1307
+ process.exit(2);
1308
+ return;
1309
+ }
1310
+ }
1185
1311
  if (!agent) {
1186
- process.stderr.write('sickr: usage — `sickr run <agent> [--mode auto|interactive] [args...]` (agent: claude | codex | <bin name>)\n');
1312
+ process.stderr.write('sickr: usage — `sickr run <agent> [--mode auto|interactive] [args...]` or `sickr run --agent-id <id>`\n');
1187
1313
  process.exit(1);
1188
1314
  return;
1189
1315
  }
1190
1316
  // Pass through every non-sickr flag plus positional tail to the agent.
1191
- const passthroughFlags = flags.filter((f) => f !== '--verbose' && f !== '-v');
1192
- const agentArgs = [...passthroughFlags, ...positional.slice(1)];
1317
+ const passthroughFlags = flags.filter((f) => f !== '--verbose' && f !== '-v' && f !== '--agent-id');
1318
+ const agentArgs = configuredArgs ?? [...passthroughFlags, ...positional.slice(1)];
1193
1319
  const verbose = flags.includes('--verbose') || flags.includes('-v');
1194
1320
  if (!(await requireReplayPro('sickr run'))) {
1195
1321
  process.exit(3);
@@ -1,6 +1,6 @@
1
1
  // PreToolUse (not PostToolUse) captures each tool action once — hooking both
2
2
  // would record every tool twice. Stop carries the assistant's final response.
3
- const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
3
+ const DEFAULT_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
4
4
  const TAG = 'npx @sickr/cli record';
5
5
  const LEGACY_TAGS = ['@sickr/replay record', 'npx sickr record'];
6
6
  /**
@@ -8,31 +8,34 @@ const LEGACY_TAGS = ['@sickr/replay record', 'npx sickr record'];
8
8
  * Idempotent — re-running never duplicates the SICKR hook. Preserves any
9
9
  * existing unrelated hooks.
10
10
  */
11
- export function mergeHooks(settings, recordCommand) {
11
+ export function mergeHooksForEvents(settings, recordCommand, events = DEFAULT_EVENTS) {
12
12
  const next = { ...(settings ?? {}) };
13
13
  next.hooks = { ...(next.hooks ?? {}) };
14
14
  const command = /\brecord\b/.test(recordCommand) ? recordCommand : `${recordCommand} record`;
15
- for (const ev of EVENTS) {
15
+ for (const ev of events) {
16
16
  const groups = Array.isArray(next.hooks[ev]) ? [...next.hooks[ev]] : [];
17
17
  const serialized = JSON.stringify(groups);
18
18
  const present = serialized.includes(TAG) || LEGACY_TAGS.some((tag) => serialized.includes(tag));
19
19
  if (!present)
20
- groups.push({ hooks: [{ type: 'command', command }] });
20
+ groups.push({ hooks: [{ type: 'command', command, name: 'SICKR recorder', timeout: 10000 }] });
21
21
  next.hooks[ev] = groups;
22
22
  }
23
23
  return next;
24
24
  }
25
+ export function mergeHooks(settings, recordCommand) {
26
+ return mergeHooksForEvents(settings, recordCommand, DEFAULT_EVENTS);
27
+ }
25
28
  /**
26
29
  * Remove the SICKR recording hooks from a Claude Code settings object — the
27
30
  * inverse of mergeHooks. Unrelated hooks are preserved; an event left with no
28
31
  * hooks is dropped so settings.json stays clean.
29
32
  */
30
- export function removeHooks(settings) {
33
+ export function removeHooksForEvents(settings, events = DEFAULT_EVENTS) {
31
34
  const next = { ...(settings ?? {}) };
32
35
  if (!next.hooks)
33
36
  return next;
34
37
  const hooks = { ...next.hooks };
35
- for (const ev of EVENTS) {
38
+ for (const ev of events) {
36
39
  const groups = Array.isArray(hooks[ev]) ? hooks[ev] : undefined;
37
40
  if (!groups)
38
41
  continue;
@@ -48,3 +51,6 @@ export function removeHooks(settings) {
48
51
  next.hooks = hooks;
49
52
  return next;
50
53
  }
54
+ export function removeHooks(settings) {
55
+ return removeHooksForEvents(settings, DEFAULT_EVENTS);
56
+ }
package/dist/providers.js CHANGED
@@ -10,6 +10,7 @@ export const PROVIDERS = {
10
10
  supportsPtyControl: true,
11
11
  supportsStructuredStream: false,
12
12
  settingsPath: () => join(process.cwd(), '.claude', 'settings.json'),
13
+ hookEvents: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'],
13
14
  notes: 'Hooks are installed into .claude/settings.json and Claude reads them without an extra trust command.',
14
15
  },
15
16
  codex: {
@@ -23,6 +24,7 @@ export const PROVIDERS = {
23
24
  supportsStructuredStream: false,
24
25
  settingsPath: () => join(process.cwd(), '.codex', 'hooks.json'),
25
26
  recordFlag: '--codex',
27
+ hookEvents: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'],
26
28
  notes: 'Codex requires the user to type /hooks once and trust the SICKR recorder.',
27
29
  },
28
30
  gemini: {
@@ -36,29 +38,30 @@ export const PROVIDERS = {
36
38
  supportsStructuredStream: false,
37
39
  settingsPath: () => join(process.cwd(), '.gemini', 'settings.json'),
38
40
  recordFlag: '--gemini',
39
- notes: 'Gemini CLI has a hooks surface; SICKR keeps it behind an adapter so trust behavior can be handled per release.',
41
+ hookEvents: ['SessionStart', 'BeforeAgent', 'BeforeTool', 'AfterAgent', 'SessionEnd'],
42
+ notes: 'Gemini CLI hooks use BeforeAgent/BeforeTool/AfterAgent events and require JSON-safe hook output.',
40
43
  },
41
- cursor: {
42
- provider: 'cursor',
43
- displayName: 'Cursor CLI',
44
- recordLabel: 'Cursor',
45
- defaultCommand: 'cursor-agent',
44
+ ollama: {
45
+ provider: 'ollama',
46
+ displayName: 'Ollama',
47
+ recordLabel: 'Ollama',
48
+ defaultCommand: 'ollama',
46
49
  supportsHooks: false,
47
50
  requiresManualHookTrust: false,
48
51
  supportsPtyControl: true,
49
- supportsStructuredStream: true,
50
- notes: 'Cursor is best captured through headless stream-json for workflow jobs; interactive control uses the PTY wrapper.',
52
+ supportsStructuredStream: false,
53
+ notes: 'Ollama is the first supported local runtime. Configure the model in workflow.sickr.ai and run through the PTY wrapper.',
51
54
  },
52
55
  local: {
53
56
  provider: 'local',
54
57
  displayName: 'Local LLM',
55
58
  recordLabel: 'Local',
56
- defaultCommand: 'sickr-local-agent',
59
+ defaultCommand: 'ollama',
57
60
  supportsHooks: false,
58
61
  requiresManualHookTrust: false,
59
62
  supportsPtyControl: true,
60
63
  supportsStructuredStream: true,
61
- notes: 'Local models should run through a SICKR-owned harness against Ollama, vLLM, llama.cpp, or an OpenAI-compatible endpoint.',
64
+ notes: 'Custom local runtimes use the configured command from workflow.sickr.ai. Ollama is the supported default.',
62
65
  },
63
66
  };
64
67
  export function providerForAgent(agent) {
@@ -70,9 +73,9 @@ export function providerForAgent(agent) {
70
73
  return 'codex';
71
74
  if (base === 'gemini')
72
75
  return 'gemini';
73
- if (base === 'cursor' || base === 'cursor-agent')
74
- return 'cursor';
75
- if (base === 'ollama' || base === 'vllm' || base === 'llama.cpp' || base === 'llama-server' || base === 'sickr-local-agent')
76
+ if (base === 'ollama')
77
+ return 'ollama';
78
+ if (base === 'vllm' || base === 'llama.cpp' || base === 'llama-server' || base === 'sickr-local-agent')
76
79
  return 'local';
77
80
  return null;
78
81
  }
package/dist/recorder.js CHANGED
@@ -99,14 +99,23 @@ export function mapEvent(cc, now = new Date(), ctx = {}) {
99
99
  case 'SessionStart':
100
100
  return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), ...base };
101
101
  case 'UserPromptSubmit':
102
+ case 'BeforeAgent':
102
103
  return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), ...base };
103
104
  case 'Stop': {
104
105
  // Codex hands us the reply directly; Claude Code we read from the transcript.
105
106
  const text = String(cc.last_assistant_message ?? '') || (cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '');
106
107
  return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), ...base };
107
108
  }
109
+ case 'AfterAgent': {
110
+ const text = String(cc.prompt_response ?? cc.last_assistant_message ?? '');
111
+ return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), ...base };
112
+ }
113
+ case 'SessionEnd':
114
+ return { kind: 'stop', label: 'Session', detail: redact(String(cc.reason ?? 'ended')), ...base };
108
115
  case 'PreToolUse':
109
- case 'PostToolUse': {
116
+ case 'BeforeTool':
117
+ case 'PostToolUse':
118
+ case 'AfterTool': {
110
119
  const tool = String(cc.tool_name ?? 'tool');
111
120
  const input = (cc.tool_input ?? {});
112
121
  let raw;
package/dist/run.js CHANGED
@@ -28,7 +28,7 @@ import { execFileSync } from 'node:child_process';
28
28
  import { setTimeout as sleep } from 'node:timers/promises';
29
29
  import { readCredentials } from './auth.js';
30
30
  import { runsDir } from './recorder.js';
31
- import { mergeHooks, removeHooks } from './hookConfig.js';
31
+ import { mergeHooksForEvents, removeHooksForEvents } from './hookConfig.js';
32
32
  import { LIVE_BASE, decodeWsPayload, splitJsonObjects } from './live.js';
33
33
  import { PROVIDERS, providerForAgent, recordCommandFor } from './providers.js';
34
34
  /** Resolve the agent binary path. Precedence:
@@ -137,6 +137,93 @@ export function normalizeRunEventForRunner(event, identity) {
137
137
  identity.sessions.add(event.session);
138
138
  return event;
139
139
  }
140
+ export function stripAnsi(input) {
141
+ return input.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '');
142
+ }
143
+ export function cleanPtyResponse(input, lastPrompt = '') {
144
+ const prompt = lastPrompt.trim();
145
+ return stripAnsi(input)
146
+ .replace(/\r/g, '\n')
147
+ .split('\n')
148
+ .map((line) => line.replace(/^>+\s?/, '').trimEnd())
149
+ .filter((line) => {
150
+ const trimmed = line.trim();
151
+ if (!trimmed)
152
+ return false;
153
+ if (prompt && trimmed === prompt)
154
+ return false;
155
+ if (trimmed === 'Send a message (/? for help)')
156
+ return false;
157
+ return true;
158
+ })
159
+ .join('\n')
160
+ .trim();
161
+ }
162
+ export class HooklessPtyEventSynth {
163
+ identity;
164
+ send;
165
+ responseDelayMs;
166
+ inputBuffer = '';
167
+ outputBuffer = '';
168
+ lastPrompt = '';
169
+ responseTimer = null;
170
+ constructor(identity, send, responseDelayMs = 700) {
171
+ this.identity = identity;
172
+ this.send = send;
173
+ this.responseDelayMs = responseDelayMs;
174
+ this.identity.sessions.add(this.sessionId());
175
+ }
176
+ sessionId() {
177
+ return this.identity.runner.slice(0, 12);
178
+ }
179
+ start() {
180
+ this.sendEvent('start', this.identity.agent, `${this.identity.agent} PTY run started`);
181
+ }
182
+ observeInput(chunk) {
183
+ this.inputBuffer += chunk;
184
+ const submitIndex = Math.max(this.inputBuffer.lastIndexOf('\r'), this.inputBuffer.lastIndexOf('\n'));
185
+ if (submitIndex < 0)
186
+ return;
187
+ const submitted = this.inputBuffer.slice(0, submitIndex);
188
+ this.inputBuffer = this.inputBuffer.slice(submitIndex + 1);
189
+ this.recordPrompt(submitted);
190
+ }
191
+ recordPrompt(text) {
192
+ const prompt = stripAnsi(text).replace(/\r/g, '\n').trim();
193
+ if (!prompt)
194
+ return;
195
+ this.lastPrompt = prompt;
196
+ this.sendEvent('prompt', 'Prompt', prompt);
197
+ }
198
+ observeOutput(chunk) {
199
+ this.outputBuffer += chunk;
200
+ if (this.responseTimer)
201
+ clearTimeout(this.responseTimer);
202
+ this.responseTimer = setTimeout(() => this.flushResponse(), this.responseDelayMs);
203
+ }
204
+ flushResponse() {
205
+ if (this.responseTimer) {
206
+ clearTimeout(this.responseTimer);
207
+ this.responseTimer = null;
208
+ }
209
+ const detail = cleanPtyResponse(this.outputBuffer, this.lastPrompt);
210
+ this.outputBuffer = '';
211
+ if (!detail)
212
+ return;
213
+ this.sendEvent('response', this.identity.agent, detail);
214
+ }
215
+ sendEvent(kind, label, detail) {
216
+ this.send({
217
+ kind,
218
+ label,
219
+ detail,
220
+ at: new Date().toISOString(),
221
+ agent: this.identity.agent,
222
+ session: this.sessionId(),
223
+ runner: this.identity.runner,
224
+ });
225
+ }
226
+ }
140
227
  export function decideSteer(msg, defaultMode = 'pty') {
141
228
  const text = String(msg.text ?? '');
142
229
  if (!text)
@@ -237,7 +324,8 @@ export function ensureRecordingHooks(agent) {
237
324
  // load (tiny boot perf + lets tests run without the module).
238
325
  // eslint-disable-next-line @typescript-eslint/no-require-imports
239
326
  const command = recordCommandFor(provider);
240
- const merged = mergeHooks(removeHooks(existing), command);
327
+ const events = adapter.hookEvents ?? ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
328
+ const merged = mergeHooksForEvents(removeHooksForEvents(existing, events), command, events);
241
329
  mkdirSync(join(settingsPath, '..'), { recursive: true });
242
330
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
243
331
  mkdirSync(runsDir(), { recursive: true });
@@ -288,6 +376,26 @@ export function autoModeArgsFor(agent, existingArgs) {
288
376
  // Unknown agent — no opinion. Operator should pass their own flags.
289
377
  return [];
290
378
  }
379
+ export function providerArgsFor(agent, existingArgs) {
380
+ const provider = providerForAgent(agent);
381
+ if (provider !== 'ollama')
382
+ return existingArgs;
383
+ if (existingArgs[0] === 'run' || existingArgs[0] === 'serve' || existingArgs[0] === 'pull')
384
+ return existingArgs;
385
+ const modelFlag = existingArgs.findIndex((arg) => arg === '--model');
386
+ if (modelFlag >= 0 && existingArgs[modelFlag + 1]) {
387
+ return ['run', existingArgs[modelFlag + 1], ...existingArgs.filter((_, index) => index !== modelFlag && index !== modelFlag + 1)];
388
+ }
389
+ const modelEq = existingArgs.find((arg) => arg.startsWith('--model='));
390
+ if (modelEq) {
391
+ const model = modelEq.slice('--model='.length);
392
+ return ['run', model, ...existingArgs.filter((arg) => arg !== modelEq)];
393
+ }
394
+ const model = process.env.SICKR_OLLAMA_MODEL;
395
+ if (model)
396
+ return ['run', model, ...existingArgs];
397
+ return existingArgs;
398
+ }
291
399
  export async function startRun(opts) {
292
400
  const creds = readCredentials();
293
401
  if (!creds) {
@@ -312,6 +420,22 @@ export async function startRun(opts) {
312
420
  const agentLabel = provider ? PROVIDERS[provider].recordLabel : opts.agent;
313
421
  const runnerId = randomUUID();
314
422
  const runnerIdentity = { agent: agentLabel, runner: runnerId, sessions: new Set() };
423
+ let ws = null;
424
+ const pendingEvents = [];
425
+ const publishEvent = (event) => {
426
+ if (ws?.readyState === 1) {
427
+ try {
428
+ ws.send(JSON.stringify({ kind: 'event', event }));
429
+ return;
430
+ }
431
+ catch { /* queue below */ }
432
+ }
433
+ pendingEvents.push(event);
434
+ };
435
+ const hooklessSynth = provider && !PROVIDERS[provider].supportsHooks
436
+ ? new HooklessPtyEventSynth(runnerIdentity, publishEvent)
437
+ : null;
438
+ let hooklessStarted = false;
315
439
  const cols = process.stdout.columns ?? 80;
316
440
  const rows = process.stdout.rows ?? 24;
317
441
  // mode=auto (default) injects the agent's "no prompt / full perms" flag
@@ -319,7 +443,7 @@ export async function startRun(opts) {
319
443
  // operator can't see. --mode interactive disables the injection.
320
444
  const mode = opts.mode ?? 'auto';
321
445
  const injected = mode === 'auto' ? autoModeArgsFor(opts.agent, opts.agentArgs) : [];
322
- const effectiveArgs = [...injected, ...opts.agentArgs];
446
+ const effectiveArgs = providerArgsFor(opts.agent, [...injected, ...opts.agentArgs]);
323
447
  process.stdout.write(`sickr: wrapping ${agentBin} in PTY. Watch + steer at: https://sickr.ai/r/${urlid}\n`);
324
448
  if (mode === 'auto' && injected.length > 0) {
325
449
  process.stdout.write(` mode=auto — injected ${injected.join(' ')} (use --mode interactive to disable)\n`);
@@ -343,8 +467,15 @@ export async function startRun(opts) {
343
467
  if (process.stdin.isTTY)
344
468
  process.stdin.setRawMode(true);
345
469
  process.stdin.resume();
346
- process.stdin.on('data', (chunk) => pty.write(chunk.toString('utf8')));
347
- pty.on('data', (data) => process.stdout.write(data));
470
+ process.stdin.on('data', (chunk) => {
471
+ const text = chunk.toString('utf8');
472
+ hooklessSynth?.observeInput(text);
473
+ pty.write(text);
474
+ });
475
+ pty.on('data', (data) => {
476
+ hooklessSynth?.observeOutput(data);
477
+ process.stdout.write(data);
478
+ });
348
479
  // Forward terminal resize. SIGWINCH fires when the terminal window changes.
349
480
  const resize = () => pty.resize(process.stdout.columns ?? cols, process.stdout.rows ?? rows);
350
481
  process.stdout.on('resize', resize);
@@ -364,6 +495,7 @@ export async function startRun(opts) {
364
495
  process.stdin.setRawMode(false);
365
496
  }
366
497
  catch { /* ignore */ }
498
+ hooklessSynth?.flushResponse();
367
499
  try {
368
500
  ws?.close();
369
501
  }
@@ -383,7 +515,6 @@ export async function startRun(opts) {
383
515
  pty.on('exit', (exitCode) => cleanup(exitCode ?? 0));
384
516
  // Open WS to live-service as a pusher. Same auth + url shape as live.ts.
385
517
  const offsets = readOffsets();
386
- let ws = null;
387
518
  let backoff = 1000;
388
519
  // Recursive reconnect loop. Runs forever.
389
520
  const runWs = async () => {
@@ -413,6 +544,20 @@ export async function startRun(opts) {
413
544
  ws.send(JSON.stringify({ kind: 'hello', agent: runnerIdentity.agent, runner: runnerIdentity.runner }));
414
545
  }
415
546
  catch { /* reconnect loop handles failures */ }
547
+ if (hooklessSynth && !hooklessStarted) {
548
+ hooklessStarted = true;
549
+ hooklessSynth.start();
550
+ }
551
+ while (pendingEvents.length > 0) {
552
+ const event = pendingEvents.shift();
553
+ try {
554
+ ws.send(JSON.stringify({ kind: 'event', event }));
555
+ }
556
+ catch {
557
+ pendingEvents.unshift(event);
558
+ break;
559
+ }
560
+ }
416
561
  tailTimer = setInterval(() => pumpNewLines(ws, offsets, runnerIdentity), 500);
417
562
  });
418
563
  ws.addEventListener('message', (ev) => {
@@ -452,6 +597,7 @@ export async function startRun(opts) {
452
597
  pty.write('\r');
453
598
  }
454
599
  catch { /* pty exited */ } }, 80);
600
+ hooklessSynth?.recordPrompt(m.text);
455
601
  }
456
602
  catch { /* PTY may have exited */ }
457
603
  if (opts.verbose)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/cli",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "npx @sickr/cli - replay, live look, and workflow orchestration for AI coding agents.",
6
6
  "bin": {