@hwip/cli 0.1.4 → 0.1.6

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/listener.js CHANGED
@@ -100,6 +100,7 @@ const tmux_liveness_js_1 = require("./tmux-liveness.js");
100
100
  Object.defineProperty(exports, "liveSessionNames", { enumerable: true, get: function () { return tmux_liveness_js_1.liveSessionNames; } });
101
101
  Object.defineProperty(exports, "sessionExists", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionExists; } });
102
102
  Object.defineProperty(exports, "sessionLiveness", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionLiveness; } });
103
+ const wrapper_js_1 = require("./wrapper.js");
103
104
  const run_seqs_js_1 = require("./run-seqs.js");
104
105
  const args_js_1 = require("./args.js");
105
106
  const schedule_js_1 = require("./schedule.js");
@@ -319,11 +320,14 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
319
320
  ...(e.startedAt ? { startedAt: e.startedAt } : {}),
320
321
  ...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
321
322
  ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
323
+ ...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
324
+ ...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
325
+ resumable: isResumable(e),
322
326
  lastSeenAt: e.lastSeenAt,
323
327
  }));
324
328
  };
325
329
  exports.knownSessionsToReport = knownSessionsToReport;
326
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
330
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''} ${k.resumable}`).sort().join('|');
327
331
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
328
332
  const sessionsFingerprint = (sessions) => sessions
329
333
  .map((s) => JSON.stringify([
@@ -1363,6 +1367,13 @@ const handleScheduleListRequest = (req, send) => {
1363
1367
  return true;
1364
1368
  };
1365
1369
  exports.handleScheduleListRequest = handleScheduleListRequest;
1370
+ /** A run id as the registry writes it: tmux `session_created`, epoch seconds. */
1371
+ const RUN_ID = /^\d{1,12}$/;
1372
+ /** The name a wrapper family counts from: `hwip-x-3` → `hwip-x`. */
1373
+ const familyBase = (name) => {
1374
+ const base = /^(.+)-\d+$/.exec(name)?.[1];
1375
+ return base !== undefined && (0, wrapper_js_1.familyNames)(base).includes(name) ? base : name;
1376
+ };
1366
1377
  /** What an agent conversation id looks like — a value, never an option. */
1367
1378
  const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
1368
1379
  /**
@@ -1378,6 +1389,26 @@ const resumableConversation = (agent, known) => {
1378
1389
  const args = agent.resumeArgs?.(id, known.cwd);
1379
1390
  return args ? { id, args } : null;
1380
1391
  };
1392
+ /**
1393
+ * A live session is on this run's conversation already: the run started beside
1394
+ * its name before, or the live run under it picked the conversation up. The
1395
+ * ended entry stays in the registry, so without this every tap on it would
1396
+ * start one more agent on one transcript.
1397
+ */
1398
+ const conversationRunning = (known, live) => {
1399
+ const id = known.agentSessionId;
1400
+ if (!id)
1401
+ return false;
1402
+ return [...live].some((name) => {
1403
+ const current = (0, session_registry_js_1.recallSession)(name);
1404
+ return current?.agentKind === known.agentKind && current.agentSessionId === id;
1405
+ });
1406
+ };
1407
+ /** What the report says about a run: the same answer a resume of it would act on. */
1408
+ const isResumable = (known) => {
1409
+ const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === known.agentKind);
1410
+ return agent !== undefined && resumableConversation(agent, known) !== null;
1411
+ };
1381
1412
  /**
1382
1413
  * Start a remembered session again, detached, and tell the phone what happened.
1383
1414
  * Returns true when the message was ours to answer.
@@ -1421,14 +1452,27 @@ const handleSessionResumeRequest = (req, send) => {
1421
1452
  // newest run would start claude for a phone that asked for pi — silently,
1422
1453
  // which is the exact failure this path exists to remove. A phone that names
1423
1454
  // nothing still means the newest run.
1455
+ const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
1424
1456
  const known = typeof req.agentKind === 'string'
1425
- ? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind)
1426
- : (0, session_registry_js_1.recallSession)(sessionName);
1457
+ ? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind, Date.now(), runId)
1458
+ : (0, session_registry_js_1.recallSession)(sessionName, Date.now(), runId);
1427
1459
  if (!known) {
1428
1460
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: never seen on this machine`);
1429
1461
  return reply({ error: 'unknown_session' });
1430
1462
  }
1431
- if ((0, tmux_liveness_js_1.sessionExists)(sessionName))
1463
+ // A live name is refused unless the request names a run: then the phone
1464
+ // asked for one earlier conversation, and the session holding the name is
1465
+ // a different one — so it starts beside it, under the next free name of
1466
+ // the family `hwip cc` would pick (Q41), counted from the family base so a
1467
+ // busy `<base>-2` leads to `<base>-3`. A request without a run id is an
1468
+ // older app that only knows the name, and still gets the refusal it renders.
1469
+ const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
1470
+ const target = !live.has(sessionName)
1471
+ ? sessionName
1472
+ : runId !== undefined
1473
+ ? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
1474
+ : undefined;
1475
+ if (!target || conversationRunning(known, live))
1432
1476
  return reply({ error: 'already_running' });
1433
1477
  if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
1434
1478
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
@@ -1449,14 +1493,15 @@ const handleSessionResumeRequest = (req, send) => {
1449
1493
  ? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
1450
1494
  : [];
1451
1495
  const conversation = resumableConversation(agent, known);
1452
- const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s', sessionName, '-c', known.cwd, agent.binary, ...(conversation?.args ?? []), ...sidecar]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1496
+ const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s', target, '-c', known.cwd, agent.binary, ...(conversation?.args ?? []), ...sidecar]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1453
1497
  if (started.status !== 0) {
1454
1498
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: tmux refused (${(started.stderr ?? '').trim() || 'no detail'})`);
1455
1499
  return reply({ error: 'start_failed' });
1456
1500
  }
1457
1501
  const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
1458
- (0, listener_log_js_1.log)(`⟲ resume ${sessionName}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1459
- return reply({ resumed: true });
1502
+ const beside = target === sessionName ? '' : ` as ${target}`;
1503
+ (0, listener_log_js_1.log)(`⟲ resume ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1504
+ return reply({ resumed: true, sessionName: target });
1460
1505
  };
1461
1506
  exports.handleSessionResumeRequest = handleSessionResumeRequest;
1462
1507
  /**
@@ -1616,7 +1661,7 @@ const handleSessionForgetRequest = (req, send) => {
1616
1661
  // alternative is an irreversible delete (registry AND scrollback) justified
1617
1662
  // by a question that failed to be asked. It says so in the log, because
1618
1663
  // from the phone the two refusals are the same sentence.
1619
- const runId = typeof req.runId === 'string' && /^\d{1,12}$/.test(req.runId) ? req.runId : undefined;
1664
+ const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
1620
1665
  const liveness = (0, tmux_liveness_js_1.sessionLiveness)(sessionName);
1621
1666
  if (liveness === 'unknown') {
1622
1667
  (0, listener_log_js_1.log)(`✗ forget ${sessionName}: no tmux answered, so nothing was deleted`);
@@ -1750,11 +1795,11 @@ const handleScreenHistoryRequest = (req, send) => {
1750
1795
  return reply({ error: 'capture_failed' });
1751
1796
  // Everything below this line the caller already has.
1752
1797
  const held = before;
1753
- // Scrolled past the oldest line. An empty page rather than an error — the
1754
- // view stops, and stopping is not a failure. Nothing to seal either.
1755
- if (depth <= held)
1756
- return reply({ before, content: '', lines: 0, hasMore: false });
1757
- const captured = capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1798
+ // Scrolled past the oldest line: an empty page rather than an error — the
1799
+ // view stops, and stopping is not a failure.
1800
+ const captured = depth <= held
1801
+ ? { content: '', truncated: false }
1802
+ : capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1758
1803
  if (!captured)
1759
1804
  return reply({ error: 'capture_failed' });
1760
1805
  const returned = countLines(captured.content);
@@ -1766,11 +1811,21 @@ const handleScreenHistoryRequest = (req, send) => {
1766
1811
  };
1767
1812
  if (!stream.subscriberPublicKey)
1768
1813
  return reply({ ...page, content: captured.content });
1769
- // Encrypted stream: the page rides ONLY inside the envelope, and a failed
1770
- // seal is an error rather than a plaintext page — the same rule the frame
1771
- // path follows when its encrypt fails.
1772
- (0, crypto_js_1.sealV2)('screen.history', captured.content, stream.subscriberPublicKey, req.sessionName)
1773
- .then((encrypted) => send({ ...base, ...page, encrypted }))
1814
+ // Encrypted stream: the page and its numbers ride ONLY inside the envelope
1815
+ // — the empty last page too, or a relay could end the paging with one — and
1816
+ // a failed seal is an error rather than a plaintext page, the same rule the
1817
+ // frame path follows when its encrypt fails.
1818
+ const sealed = {
1819
+ sessionName: req.sessionName,
1820
+ requestId: req.requestId,
1821
+ ...(stream.nonce ? { nonce: stream.nonce } : {}),
1822
+ epoch: stream.epoch,
1823
+ ...page,
1824
+ historySize: depth,
1825
+ content: captured.content,
1826
+ };
1827
+ (0, crypto_js_1.sealV2)('screen.history', JSON.stringify(sealed), stream.subscriberPublicKey, req.sessionName)
1828
+ .then((encrypted) => send({ ...base, encrypted }))
1774
1829
  .catch((err) => {
1775
1830
  (0, listener_log_js_1.log)(`⧉ history ${req.sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
1776
1831
  send({ ...base, error: 'encrypt_failed' });
@@ -3639,6 +3694,8 @@ const collectSessionsVerbose = () => {
3639
3694
  startedAt: s.createdAt,
3640
3695
  tmuxRunId: runIdOfIso(s.createdAt),
3641
3696
  agentSessionId: ownResumeId(s.name),
3697
+ providerSessionName: s.providerSessionName,
3698
+ providerSessionNameSource: s.providerSessionNameSource,
3642
3699
  })));
3643
3700
  return { sessions, rejected, targets, subagentTranscripts, recordable };
3644
3701
  };
@@ -4617,7 +4674,7 @@ const openSealedCommand = async (push, deps) => {
4617
4674
  return { text: opened.body ?? '', sender: senderKey, files: named, keys, ifPrompt, schedule };
4618
4675
  };
4619
4676
  /**
4620
- * A phone's request to make or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4677
+ * A phone's request to make, change or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4621
4678
  * the session's rate budget the way a typed command does and reports a refusal the way every command does.
4622
4679
  * Never types anything. A push no one addressed to this machine is not acted on: it changes files, and a
4623
4680
  * push.new reaches every listener of the account.
@@ -45,7 +45,7 @@ export declare const MCP_SERVERS_ENTRY: {
45
45
  */
46
46
  export declare const OPENCODE_MCP_ENTRY: {
47
47
  type: string;
48
- command: ("mcp" | "hwip")[];
48
+ command: ("hwip" | "mcp")[];
49
49
  enabled: boolean;
50
50
  };
51
51
  export declare const GEMINI_MCP_ADD: string;
@@ -380,6 +380,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
380
380
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
381
381
  readonly projectTranscript: TranscriptProjector;
382
382
  readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
383
+ readonly resumeArgs: (id: string) => string[] | null;
383
384
  }, {
384
385
  readonly kind: "cursor";
385
386
  readonly displayName: "Cursor CLI";
@@ -400,6 +401,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
400
401
  readonly subcommands: readonly ["hermes"];
401
402
  readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
402
403
  readonly resolveResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
404
+ readonly resumeArgs: (id: string) => string[];
403
405
  }, {
404
406
  readonly kind: "pi";
405
407
  readonly displayName: "Pi";
@@ -409,6 +411,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
409
411
  readonly projectTranscript: TranscriptProjector;
410
412
  readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
411
413
  readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => ProviderName | null;
414
+ readonly resumeArgs: (id: string, cwd: string) => string[] | null;
412
415
  }, {
413
416
  readonly kind: "opencode";
414
417
  readonly displayName: "OpenCode";
@@ -462,6 +462,15 @@ const claudeTranscriptPath = (paneCwd, panePid) => {
462
462
  return claudeConversationFile(paneCwd, sessionId);
463
463
  };
464
464
  exports.claudeTranscriptPath = claudeTranscriptPath;
465
+ /** A regular file is there: what a resume flag needs before it names one. */
466
+ const isFileAt = (path) => {
467
+ try {
468
+ return (0, fs_1.statSync)(path).isFile();
469
+ }
470
+ catch {
471
+ return false;
472
+ }
473
+ };
465
474
  /**
466
475
  * Where Claude Code keeps conversation `sessionId` begun in `cwd`, or null
467
476
  * when no such file is there. Proven, not computed: the pid path never touches
@@ -470,12 +479,7 @@ exports.claudeTranscriptPath = claudeTranscriptPath;
470
479
  */
471
480
  const claudeConversationFile = (cwd, sessionId) => {
472
481
  const path = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(cwd), `${sessionId}.jsonl`);
473
- try {
474
- return (0, fs_1.statSync)(path).isFile() ? path : null;
475
- }
476
- catch {
477
- return null;
478
- }
482
+ return isFileAt(path) ? path : null;
479
483
  };
480
484
  // ── pi ───────────────────────────────────────────────────────────
481
485
  /**
@@ -524,6 +528,11 @@ const piSessionDirName = (cwd) => {
524
528
  const resolved = (0, path_1.resolve)(expanded);
525
529
  return `--${resolved.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
526
530
  };
531
+ /** `--session <file>` for a pi session still on disk, or null. */
532
+ const piSessionFile = (cwd, id) => {
533
+ const path = (0, path_1.join)(piSessionsDir(), piSessionDirName(cwd), `${id}.jsonl`);
534
+ return isFileAt(path) ? ['--session', path] : null;
535
+ };
527
536
  /**
528
537
  * Newest session file pi wrote for this directory.
529
538
  *
@@ -849,6 +858,15 @@ const codexSessionsDir = () => {
849
858
  const home = override ? override.replace(/^~(?=\/|$)/, (0, os_1.homedir)()) : CODEX_DIR;
850
859
  return (0, path_1.join)(home, 'sessions');
851
860
  };
861
+ /** `rollout-YYYY-MM-DDThh-mm-ss-<uuid>`: the day it shards under, and the thread it carries. */
862
+ const CODEX_ROLLOUT = /^rollout-(\d{4})-(\d{2})-(\d{2})T[\d-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
863
+ /** The thread id of a rollout that is still on disk, or null. */
864
+ const codexRolloutThread = (rolloutId) => {
865
+ const m = CODEX_ROLLOUT.exec(rolloutId);
866
+ if (!m)
867
+ return null;
868
+ return isFileAt((0, path_1.join)(codexSessionsDir(), m[1], m[2], m[3], `${rolloutId}.jsonl`)) ? m[4] : null;
869
+ };
852
870
  /**
853
871
  * The two newest `sessions/<YYYY>/<MM>/<DD>` shards.
854
872
  *
@@ -1211,8 +1229,15 @@ const REMOTE_AGENT_TABLE = [
1211
1229
  resolveSessionName: exports.detectCodexSessionName,
1212
1230
  resolveTranscript: (paneCwd, panePid) => (0, exports.codexTranscriptPath)(paneCwd, panePid),
1213
1231
  projectTranscript: codex_transcript_js_1.projectCodexEntries,
1214
- // Read only to tell a resumed conversation's run (no `resumeArgs`): the rollout a resume appends to.
1232
+ // The rollout a resume appends to, by file name.
1215
1233
  resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.codexTranscriptPath)(paneCwd, panePid), panePid),
1234
+ // `codex resume` takes the thread's UUID; the rollout's file name reads as
1235
+ // a thread NAME and starts a new conversation (measured on 0.163.0). An
1236
+ // unknown UUID exits at once, so only a rollout still on disk.
1237
+ resumeArgs: (id) => {
1238
+ const thread = codexRolloutThread(id);
1239
+ return thread ? ['resume', thread] : null;
1240
+ },
1216
1241
  },
1217
1242
  {
1218
1243
  // The pane binary is `cursor-agent`, Cursor's terminal TUI — NOT the
@@ -1250,6 +1275,10 @@ const REMOTE_AGENT_TABLE = [
1250
1275
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
1251
1276
  resolveSessionName: exports.detectHermesSessionName,
1252
1277
  resolveResumeId: exports.detectHermesResumeId,
1278
+ // Measured: `--resume <id>` carries the conversation on, and an id the
1279
+ // store lost starts a new session rather than exit — so, like opencode,
1280
+ // not checked against the store.
1281
+ resumeArgs: (id) => ['--resume', id],
1253
1282
  },
1254
1283
  {
1255
1284
  kind: 'pi',
@@ -1260,6 +1289,10 @@ const REMOTE_AGENT_TABLE = [
1260
1289
  projectTranscript: pi_transcript_js_1.projectPiEntries,
1261
1290
  resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.piTranscriptPath)(paneCwd, panePid), panePid),
1262
1291
  resolveSessionName: (paneCwd, panePid) => (0, exports.detectPiSessionName)(paneCwd, panePid),
1292
+ // The session file itself: `--session` takes a path or a UUID, and the
1293
+ // recorded id is the file name, which it does not match. A session pi
1294
+ // cannot find exits at once (measured), so only a file still on disk.
1295
+ resumeArgs: (id, cwd) => piSessionFile(cwd, id),
1263
1296
  },
1264
1297
  {
1265
1298
  kind: 'opencode',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `hwip schedule` — add, list and remove scheduled sessions (F8).
2
+ * `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
3
3
  *
4
4
  * This only writes and reads the files in `schedule.ts`. The listener starts the sessions
5
5
  * (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
@@ -16,6 +16,11 @@ export interface ScheduleCommandDeps {
16
16
  listenerRunning: () => boolean;
17
17
  /** The agent kinds a prompt can be scheduled for: those whose detection rules can say `idle` (`schedule-ready.ts`). */
18
18
  promptKinds: () => readonly string[];
19
+ /**
20
+ * Has a running listener of an older build replaced (`ensureListenerRunning`): an old one would skip a file it cannot read.
21
+ * False when one still runs after it.
22
+ */
23
+ refreshListener: () => Promise<boolean>;
19
24
  out: (line: string) => void;
20
25
  err: (line: string) => void;
21
26
  }
@@ -27,6 +32,11 @@ interface Built {
27
32
  }
28
33
  /** The schedule `hwip schedule add <words>` asks for, before it has an id. */
29
34
  export declare const buildSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<Checked<Built>>;
35
+ /**
36
+ * Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
37
+ * and `hwip schedule edit --json` promises one JSON line there.
38
+ */
39
+ export declare const onStderr: (work: () => Promise<void>) => Promise<void>;
30
40
  /** `words`: argv after `schedule`. Returns the exit code. */
31
41
  export declare const runSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<number>;
32
42
  export declare const liveScheduleDeps: () => ScheduleCommandDeps;
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
- * `hwip schedule` — add, list and remove scheduled sessions (F8).
3
+ * `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
4
4
  *
5
5
  * This only writes and reads the files in `schedule.ts`. The listener starts the sessions
6
6
  * (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.buildSchedule = exports.describeWhen = void 0;
9
+ exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.onStderr = exports.buildSchedule = exports.describeWhen = void 0;
10
10
  const args_js_1 = require("./args.js");
11
11
  const schedule_js_1 = require("./schedule.js");
12
12
  const config_js_1 = require("./config.js");
@@ -16,6 +16,7 @@ const schedule_ready_js_1 = require("./schedule-ready.js");
16
16
  const wrapper_js_1 = require("./wrapper.js");
17
17
  const worktree_js_1 = require("./worktree.js");
18
18
  const USAGE = "usage: hwip schedule add <agent> (--at HH:MM [--days daily|weekdays|mon,wed,…] | --every <N>m|<N>h) [--prompt <text>] [--label <x>] [--wt <branch>] [--max-runtime <N>m|<N>h] [-- <agent args…>]\n"
19
+ + ' hwip schedule edit <id> [--at HH:MM] [--days …] [--every <N>m|<N>h] [--prompt <text>] [--max-runtime <N>m|<N>h]\n'
19
20
  + ' hwip schedule [list] [--json]\n'
20
21
  + ' hwip schedule rm <id>';
21
22
  const LISTENER_NOTE = "hwip: schedules start only while the listener runs ('hwip listener --install-service' keeps it running at login)";
@@ -142,7 +143,7 @@ const handleAdd = async (words, json, deps) => {
142
143
  return 2;
143
144
  }
144
145
  const { schedule } = added;
145
- const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), Date.parse(schedule.createdAt));
146
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
146
147
  if (json) {
147
148
  deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
148
149
  }
@@ -159,7 +160,7 @@ const handleList = (json, deps) => {
159
160
  const state = (0, schedule_js_1.readScheduleState)().schedules;
160
161
  const now = deps.now();
161
162
  const rows = schedules.map((schedule) => {
162
- const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now, Date.parse(schedule.createdAt));
163
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule));
163
164
  const last = state[schedule.id]?.last;
164
165
  return { schedule, next, last, running: state[schedule.id]?.running !== undefined };
165
166
  });
@@ -216,6 +217,108 @@ const handleRemove = (id, deps) => {
216
217
  deps.out(`removed ${id}. A session it already started keeps running; end it yourself if you want it gone.`);
217
218
  return 0;
218
219
  };
220
+ /**
221
+ * Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
222
+ * and `hwip schedule edit --json` promises one JSON line there.
223
+ */
224
+ const onStderr = async (work) => {
225
+ const log = console.log;
226
+ console.log = console.error;
227
+ try {
228
+ await work();
229
+ }
230
+ finally {
231
+ console.log = log;
232
+ }
233
+ };
234
+ exports.onStderr = onStderr;
235
+ /** The schedule's new `when` from the flags given: what is not given is kept, so `--at` keeps the days and `--days` the time. */
236
+ const editedWhen = (current, at, days, every) => {
237
+ if (at === undefined && days === undefined && every === undefined)
238
+ return { ok: true, value: undefined };
239
+ if (every !== undefined || at !== undefined) {
240
+ const keptDays = current.kind === 'daily' ? current.days.map((day) => schedule_js_1.DAY_NAMES[day]).join(',') : undefined;
241
+ return (0, schedule_js_1.parseWhen)({ at, every, days: days ?? (every === undefined ? keptDays : undefined) });
242
+ }
243
+ if (current.kind !== 'daily')
244
+ return { ok: false, error: '--days needs --at HH:MM for a schedule that runs --every' };
245
+ return (0, schedule_js_1.parseWhen)({ at: current.at, days });
246
+ };
247
+ const buildChange = (current, flags, deps) => {
248
+ if (flags._arg1)
249
+ return { ok: false, error: `unexpected word '${String(flags._arg1)}'` };
250
+ const fixed = ['label', 'wt'].find((name) => flags[name] !== undefined);
251
+ if (fixed)
252
+ return { ok: false, error: `--${fixed} cannot be edited: the session is named for it; remove the schedule and add it again` };
253
+ const read = (name) => flagText(flags, name);
254
+ const [at, days, every, prompt, maxRuntime] = ['at', 'days', 'every', 'prompt', 'max-runtime'].map(read);
255
+ const bad = [at, days, every, prompt, maxRuntime].find((r) => !r.ok);
256
+ if (bad && !bad.ok)
257
+ return bad;
258
+ const value = (r) => (r.ok ? r.value : undefined);
259
+ const when = editedWhen(current.when, value(at), value(days), value(every));
260
+ if (!when.ok)
261
+ return when;
262
+ const change = when.value === undefined ? {} : { when: when.value };
263
+ const text = value(prompt);
264
+ if (text !== undefined) {
265
+ if (text.trim() === '')
266
+ return { ok: false, error: '--prompt must not be empty' };
267
+ if (!deps.promptKinds().includes(current.agentKind)) {
268
+ return { ok: false, error: `${current.agentKind} cannot take a scheduled --prompt: hwip has no rule that tells when it is idle` };
269
+ }
270
+ change.prompt = text;
271
+ }
272
+ if (value(maxRuntime) !== undefined) {
273
+ const parsed = (0, schedule_js_1.parseDuration)(value(maxRuntime), '--max-runtime', '90m or 2h');
274
+ if (!parsed.ok)
275
+ return parsed;
276
+ change.maxRuntimeMin = parsed.value;
277
+ }
278
+ if (Object.keys(change).length === 0)
279
+ return { ok: false, error: 'edit needs --at, --days, --every, --prompt or --max-runtime' };
280
+ return { ok: true, value: change };
281
+ };
282
+ const handleEdit = async (words, json, deps) => {
283
+ // `parseArgs` reads the words after the program name and script; the first word is the id.
284
+ const flags = (0, args_js_1.parseArgs)(['node', 'hwip', ...words]);
285
+ const id = flags._command === undefined ? '' : String(flags._command);
286
+ if (!(0, schedule_js_1.isScheduleId)(id)) {
287
+ deps.err(id ? `hwip schedule: '${id}' is not a schedule id` : 'hwip schedule: edit needs a schedule id');
288
+ return 2;
289
+ }
290
+ const current = (0, schedule_js_1.listSchedules)().schedules.find((schedule) => schedule.id === id);
291
+ if (!current) {
292
+ deps.err(`hwip schedule: no schedule ${id}`);
293
+ return 1;
294
+ }
295
+ const change = buildChange(current, flags, deps);
296
+ if (!change.ok) {
297
+ deps.err(`hwip schedule: ${change.error}`);
298
+ return 2;
299
+ }
300
+ // A new time writes `updatedAt`, which an older listener does not know: it would skip the file and the schedule would stop.
301
+ if (deps.listenerRunning() && !(await deps.refreshListener()) && change.value.when !== undefined) {
302
+ deps.err('hwip schedule: the listener running is an older hwip that could not be replaced, and it would stop running this schedule: restart it, then edit again');
303
+ return 1;
304
+ }
305
+ const updated = (0, schedule_js_1.updateSchedule)(id, change.value, deps.now());
306
+ if (!updated.ok) {
307
+ deps.err(`hwip schedule: ${updated.error}`);
308
+ return updated.failure === 'missing' ? 1 : 2;
309
+ }
310
+ const { schedule } = updated;
311
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
312
+ if (json) {
313
+ deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
314
+ }
315
+ else {
316
+ deps.out(`updated ${schedule.id}: ${(0, exports.describeWhen)(schedule.when)}${next === null ? '' : `, next ${formatTime(next)}`} (ends after ${schedule.maxRuntimeMin}m)`);
317
+ }
318
+ if (!deps.listenerRunning())
319
+ deps.err(LISTENER_NOTE);
320
+ return 0;
321
+ };
219
322
  const JSON_ON = new Set(['--json', '--json=true', '--json=1', '--json=yes']);
220
323
  /** `words`: argv after `schedule`. Returns the exit code. */
221
324
  const runSchedule = async (words, deps) => {
@@ -229,6 +332,8 @@ const runSchedule = async (words, deps) => {
229
332
  switch (sub) {
230
333
  case 'add':
231
334
  return handleAdd(rest, json, deps);
335
+ case 'edit':
336
+ return handleEdit(rest, json, deps);
232
337
  case 'list':
233
338
  if (rest.length > 0)
234
339
  break;
@@ -252,6 +357,10 @@ const liveScheduleDeps = () => ({
252
357
  validBranch: (root, name) => (0, worktree_js_1.isValidBranchName)(root, name),
253
358
  listenerRunning: () => (0, listener_process_js_1.runningListenerPid)() !== null,
254
359
  promptKinds: () => (0, schedule_ready_js_1.promptableScheduleKinds)(remote_agents_js_1.REMOTE_AGENTS.map((a) => a.kind)),
360
+ refreshListener: async () => {
361
+ await (0, exports.onStderr)(() => (0, wrapper_js_1.ensureListenerRunning)(false));
362
+ return (0, listener_process_js_1.runningListenerPid)() === null || !(0, wrapper_js_1.listenerVersionDrifted)((0, listener_process_js_1.runningListenerVersion)(), config_js_1.VERSION);
363
+ },
255
364
  out: (line) => console.log(line),
256
365
  err: (line) => console.error(line),
257
366
  });
@@ -2,7 +2,8 @@
2
2
  * Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
3
3
  * directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
4
4
  * whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
5
- * that is running it (see `LISTENER_LAUNCH_ENV`).
5
+ * that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
6
+ * (agent-dirs.ts): the service's own PATH has none of them.
6
7
  */
7
8
  import { execFile as nodeExecFile } from 'child_process';
8
9
  import type { LaunchResult } from './schedule-runner.js';
@@ -10,6 +11,7 @@ export interface LaunchDeps {
10
11
  execFile?: typeof nodeExecFile;
11
12
  cliPath?: () => string | null;
12
13
  env?: NodeJS.ProcessEnv;
14
+ agentPath?: (path: string | undefined) => string;
13
15
  }
14
16
  export declare const launchHwip: (argv: string[], cwd: string, timeoutMs: number, deps?: LaunchDeps) => Promise<LaunchResult>;
15
17
  //# sourceMappingURL=schedule-launch.d.ts.map
@@ -3,11 +3,13 @@
3
3
  * Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
4
4
  * directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
5
5
  * whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
6
- * that is running it (see `LISTENER_LAUNCH_ENV`).
6
+ * that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
7
+ * (agent-dirs.ts): the service's own PATH has none of them.
7
8
  */
8
9
  Object.defineProperty(exports, "__esModule", { value: true });
9
10
  exports.launchHwip = void 0;
10
11
  const child_process_1 = require("child_process");
12
+ const agent_dirs_js_1 = require("./agent-dirs.js");
11
13
  const config_js_1 = require("./config.js");
12
14
  const listener_process_js_1 = require("./listener-process.js");
13
15
  const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) => {
@@ -17,6 +19,7 @@ const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) =>
17
19
  const env = { ...(deps.env ?? process.env), [listener_process_js_1.LISTENER_LAUNCH_ENV]: '1' };
18
20
  for (const key of config_js_1.PROJECT_DIR_ENV_VARS)
19
21
  delete env[key];
22
+ env.PATH = (deps.agentPath ?? agent_dirs_js_1.withAgentDirs)(env.PATH);
20
23
  (deps.execFile ?? child_process_1.execFile)(process.execPath, [cliPath, ...argv], { cwd, env, encoding: 'utf-8', timeout: timeoutMs }, (err, _stdout, stderr) => {
21
24
  resolve(err ? { ok: false, error: stderr || err.message } : { ok: true });
22
25
  });
@@ -15,6 +15,11 @@ export interface PhoneScheduleItem {
15
15
  session: string;
16
16
  when: ScheduleWhen;
17
17
  prompt?: string;
18
+ /**
19
+ * The phone cannot change the prompt: what is here is not all of it (cut, or left out for room), or the schedule has
20
+ * agent arguments the computer gave. The app shows it and leaves it to `hwip schedule edit`.
21
+ */
22
+ promptLocked?: true;
18
23
  maxRuntimeMin: number;
19
24
  /** ISO time of the next run; null when there is none. */
20
25
  nextAt: string | null;
@@ -70,7 +75,8 @@ export interface PhoneScheduleDeps {
70
75
  export declare const livePhoneScheduleDeps: PhoneScheduleDeps;
71
76
  /**
72
77
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
73
- * recorded session `session`, or `{ op: 'remove', id }` for the schedule whose session that is. Nothing here
78
+ * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
79
+ * schedule whose session that is. Nothing here
74
80
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
75
81
  */
76
82
  export declare const applyPhoneSchedule: (request: unknown, session: string, deps?: PhoneScheduleDeps) => PhoneScheduleResult;