@hwip/cli 0.1.3 → 0.1.5

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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.detectHermesSessionName = exports.sqliteJson = exports.pickRowByProcStart = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeSessionNameByPid = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
3
+ exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.detectHermesResumeId = exports.detectHermesSessionName = exports.sqliteJson = exports.transcriptIdSince = exports.paneSince = exports.pickRowByProcStart = exports.detectPiSessionName = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeProviderName = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
4
4
  /**
5
5
  * Remote-control agent registry — the single table behind every
6
6
  * `hwip <agent>` subcommand, the listener's pane matching, and the
@@ -22,6 +22,7 @@ const path_1 = require("path");
22
22
  const child_process_1 = require("child_process");
23
23
  const transcript_tail_js_1 = require("./transcript-tail.js");
24
24
  const pi_transcript_js_1 = require("./pi-transcript.js");
25
+ const session_title_js_1 = require("./session-title.js");
25
26
  const codex_transcript_js_1 = require("./codex-transcript.js");
26
27
  const gemini_transcript_js_1 = require("./gemini-transcript.js");
27
28
  const opencode_transcript_js_1 = require("./opencode-transcript.js");
@@ -158,6 +159,7 @@ const readPidSessionRecords = () => {
158
159
  sessionId: raw.sessionId,
159
160
  cwd: raw.cwd,
160
161
  ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
162
+ ...(typeof raw.nameSource === 'string' ? { nameSource: raw.nameSource } : {}),
161
163
  });
162
164
  }
163
165
  }
@@ -395,14 +397,35 @@ const detectClaudeSessionByPid = (panePid, paneCwd, deps = {}) => {
395
397
  exports.detectClaudeSessionByPid = detectClaudeSessionByPid;
396
398
  const detectClaudeSessionIdByPid = (panePid, paneCwd, deps = {}) => (0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.sessionId ?? null;
397
399
  exports.detectClaudeSessionIdByPid = detectClaudeSessionIdByPid;
400
+ /** Claude transcripts whose latest `ai-title` the listener keeps, read incrementally. */
401
+ const claudeTitles = (0, session_title_js_1.createTailWatch)(session_title_js_1.latestClaudeAiTitle);
398
402
  /**
399
- * The name of the session running in this pane. No mtime fallback exists here:
400
- * the transcript filenames the heuristic walks carry a UUID and nothing else,
401
- * so an older CC without `~/.claude/sessions` yields no name and the phone
402
- * keeps the computed label.
403
+ * What the phone should call the Claude Code session in this pane, and who
404
+ * chose it.
405
+ *
406
+ * A `/rename` (`nameSource: "user"`) wins. Otherwise the newest `ai-title` CC
407
+ * wrote into the transcript — its own summary of the work, which is what tells
408
+ * parallel sessions apart. A derived name (`hybrid-shell-8f`, from the cwd)
409
+ * is never sent: it says no more than the tmux name the phone falls back to.
410
+ * A record from an older CC carries a name but no `nameSource`; that one is
411
+ * sent as before, with no source, ahead of any ai-title, since it may be the
412
+ * user's rename. No mtime
413
+ * fallback: only the pid match says which session this pane is.
403
414
  */
404
- const detectClaudeSessionNameByPid = (panePid, paneCwd, deps = {}) => nonBlank((0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.name);
405
- exports.detectClaudeSessionNameByPid = detectClaudeSessionNameByPid;
415
+ const detectClaudeProviderName = (panePid, paneCwd, deps = {}) => {
416
+ const record = (0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps);
417
+ if (!record)
418
+ return null;
419
+ const recordName = nonBlank(record.name);
420
+ if (recordName && record.nameSource === 'user')
421
+ return { name: recordName, source: 'user' };
422
+ if (recordName && record.nameSource === undefined)
423
+ return { name: recordName };
424
+ const transcript = (deps.transcriptOf ?? claudeConversationFile)(record.cwd, record.sessionId);
425
+ const title = transcript ? claudeTitles.read(transcript) : undefined;
426
+ return title ? { name: title, source: 'auto' } : null;
427
+ };
428
+ exports.detectClaudeProviderName = detectClaudeProviderName;
406
429
  /**
407
430
  * The transcript file for the Claude Code session running in a tmux pane.
408
431
  *
@@ -559,6 +582,23 @@ const piTranscriptPath = (paneCwd, panePid) => {
559
582
  return piSessionCache.set(key, doDetectPiTranscript(paneCwd));
560
583
  };
561
584
  exports.piTranscriptPath = piTranscriptPath;
585
+ const piNames = (0, session_title_js_1.createTailWatch)(session_title_js_1.piNamesStep);
586
+ /**
587
+ * The name a pi pane's session goes by, and who chose it: the newest
588
+ * `session_info` name, which is the agent's (`'auto'`) when the hwip
589
+ * extension's tool set it — the tool leaves a marker entry with the same name
590
+ * (`PI_TITLE_MARKER`) — and the user's (`/name`) otherwise. Read from the
591
+ * transcript tail, so a name written before the last 256 KB of a session the
592
+ * listener first meets late is not found until the next one is written.
593
+ */
594
+ const detectPiSessionName = (paneCwd, panePid, transcriptOf = exports.piTranscriptPath) => {
595
+ const file = transcriptOf(paneCwd, panePid);
596
+ const names = file ? piNames.read(file) : undefined;
597
+ if (!names?.name)
598
+ return null;
599
+ return { name: names.name, source: names.marked === names.name ? 'auto' : 'user' };
600
+ };
601
+ exports.detectPiSessionName = detectPiSessionName;
562
602
  // ── Agents whose session store keeps no pid ──────────────────────
563
603
  //
564
604
  // Hermes and Codex both record a session per cwd with a creation timestamp and
@@ -596,6 +636,43 @@ const pickRowByProcStart = (rows, tsMsOf, startTimes, pids) => {
596
636
  return best?.row ?? null;
597
637
  };
598
638
  exports.pickRowByProcStart = pickRowByProcStart;
639
+ /**
640
+ * How far before the pane's start a write may land and still be the pane's: `ps` `lstart` is whole seconds. Not
641
+ * {@link PROC_MATCH_TOLERANCE_MS} — the last sitting's final write lands seconds before an `exit` and a fresh
642
+ * start, and a margin that wide would read the fresh pane as carrying it on.
643
+ */
644
+ const PANE_WRITE_MARGIN_MS = 2_000;
645
+ /**
646
+ * From when a conversation written to is this pane's: its earliest process start, less {@link PANE_WRITE_MARGIN_MS}. A
647
+ * resume writes to the conversation it picked back up, so that one is written to after the start; a fresh start
648
+ * has not written yet, so the folder's older conversations are all before it. Null when no start is known.
649
+ */
650
+ const paneSince = (panePid, deps = {}) => {
651
+ const startTimes = deps.startTimes ?? (0, exports.psStartTimes)();
652
+ const starts = [...(deps.descendants ?? (0, exports.collectDescendantPids)(panePid))]
653
+ .map((pid) => startTimes.get(pid))
654
+ .filter((t) => t !== undefined && Number.isFinite(t));
655
+ return starts.length === 0 ? null : Math.min(...starts) - PANE_WRITE_MARGIN_MS;
656
+ };
657
+ exports.paneSince = paneSince;
658
+ /**
659
+ * The conversation a transcript file holds — its name without `.jsonl` — when it was written to since `since`.
660
+ * For the agents whose resume appends to the file it picks back up (pi `-c`, Codex `resume`, Gemini `--resume`,
661
+ * measured 2026-10-20): the file is the conversation.
662
+ */
663
+ const transcriptIdSince = (path, since) => {
664
+ if (!path || since === null)
665
+ return null;
666
+ try {
667
+ return (0, fs_1.statSync)(path).mtimeMs >= since ? (0, path_1.basename)(path, '.jsonl') : null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ };
673
+ exports.transcriptIdSince = transcriptIdSince;
674
+ /** {@link transcriptIdSince} for a pane: what `resolveResumeId` answers for an agent whose transcript is a file. */
675
+ const paneTranscriptId = (path, panePid) => panePid === undefined ? null : (0, exports.transcriptIdSince)(path, (0, exports.paneSince)(panePid));
599
676
  /**
600
677
  * Read rows from a SQLite file as JSON. Null on every failure — no `sqlite3` on
601
678
  * PATH, a locked or corrupt database, a schema that moved — because a missing
@@ -652,10 +729,44 @@ const detectHermesSessionName = (paneCwd, panePid, deps = {}) => {
652
729
  return null;
653
730
  const row = (0, exports.pickRowByProcStart)(rows, (r) => (typeof r.started_at === 'number' ? r.started_at * 1_000 : null), deps.startTimes ?? (0, exports.psStartTimes)(), deps.descendants ?? (0, exports.collectDescendantPids)(panePid));
654
731
  // The auto-title is written asynchronously after the first response, so an
655
- // untitled row is a young session, not a broken one.
656
- return nonBlank(row?.title ?? undefined) ?? nonBlank(row?.display_name ?? undefined);
732
+ // untitled row is a young session, not a broken one. `title` is Hermes'
733
+ // own; `display_name` comes from outside its titler (a gateway fills it
734
+ // from the chat it serves), so it is never one to hide.
735
+ const title = nonBlank(row?.title ?? undefined);
736
+ if (title)
737
+ return { name: title, source: 'auto' };
738
+ const display = nonBlank(row?.display_name ?? undefined);
739
+ return display ? { name: display, source: 'user' } : null;
657
740
  };
658
741
  exports.detectHermesSessionName = detectHermesSessionName;
742
+ const readHermesActive = ttlMemo(SNAPSHOT_TTL_MS, () => (0, exports.sqliteJson)(HERMES_STATE_DB, `SELECT s.id AS id, s.cwd AS cwd, (SELECT max(m.timestamp) FROM messages m WHERE m.session_id = s.id) AS active
743
+ FROM sessions s WHERE s.source = 'cli' ORDER BY s.started_at DESC LIMIT ${STORE_ROW_LIMIT}`));
744
+ /**
745
+ * The Hermes session this pane carries on: in its directory, with a message since the pane started — the newest.
746
+ * A `-c` picks back up a session started long before the pane, which the start-time match above cannot see.
747
+ */
748
+ const detectHermesResumeId = (paneCwd, panePid, deps = {}) => {
749
+ if (panePid === undefined)
750
+ return null;
751
+ const raw = deps.rows !== undefined ? deps.rows : readHermesActive();
752
+ if (raw === null)
753
+ return null;
754
+ const since = (0, exports.paneSince)(panePid, deps);
755
+ if (since === null)
756
+ return null;
757
+ let best = null;
758
+ // The query's own columns; each is checked with typeof below before it is used.
759
+ for (const row of raw) {
760
+ if (row.cwd !== paneCwd || typeof row.id !== 'string' || !row.id)
761
+ continue;
762
+ if (typeof row.active !== 'number' || row.active * 1_000 < since)
763
+ continue;
764
+ if (!best || row.active > (best.active ?? 0))
765
+ best = row;
766
+ }
767
+ return best?.id ?? null;
768
+ };
769
+ exports.detectHermesResumeId = detectHermesResumeId;
659
770
  // ── Codex ────────────────────────────────────────────────────────
660
771
  const CODEX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.codex');
661
772
  const CODEX_STATE_DB = /^state_(\d+)\.sqlite$/;
@@ -715,7 +826,13 @@ const detectCodexSessionName = (paneCwd, panePid, deps = {}) => {
715
826
  if (rows.length === 0)
716
827
  return null;
717
828
  const row = (0, exports.pickRowByProcStart)(rows, (r) => r.created_at_ms ?? (typeof r.created_at === 'number' ? r.created_at * 1_000 : null), deps.startTimes ?? (0, exports.psStartTimes)(), deps.descendants ?? (0, exports.collectDescendantPids)(panePid));
718
- return nonBlank(row?.name ?? undefined) ?? nonBlank(row?.title ?? undefined);
829
+ // `name` is what the user typed (`/rename`); `title` is Codex's own —
830
+ // the first prompt, verbatim.
831
+ const name = nonBlank(row?.name ?? undefined);
832
+ if (name)
833
+ return { name, source: 'user' };
834
+ const title = nonBlank(row?.title ?? undefined);
835
+ return title ? { name: title, source: 'auto' } : null;
719
836
  };
720
837
  exports.detectCodexSessionName = detectCodexSessionName;
721
838
  /**
@@ -1031,13 +1148,9 @@ const detectOpencodeSession = (paneCwd, panePid, deps = {}) => {
1031
1148
  const raw = deps.rows !== undefined ? deps.rows : readOpencodeSessions();
1032
1149
  if (raw === null)
1033
1150
  return null;
1034
- const startTimes = deps.startTimes ?? (0, exports.psStartTimes)();
1035
- const starts = [...(deps.descendants ?? (0, exports.collectDescendantPids)(panePid))]
1036
- .map((pid) => startTimes.get(pid))
1037
- .filter((t) => t !== undefined && Number.isFinite(t));
1038
- if (starts.length === 0)
1151
+ const since = (0, exports.paneSince)(panePid, deps);
1152
+ if (since === null)
1039
1153
  return null;
1040
- const since = Math.min(...starts) - PROC_MATCH_TOLERANCE_MS;
1041
1154
  const dir = realDir(paneCwd);
1042
1155
  let best = null;
1043
1156
  for (const row of raw) {
@@ -1079,7 +1192,7 @@ const REMOTE_AGENT_TABLE = [
1079
1192
  // (older CC without ~/.claude/sessions, or ps failure).
1080
1193
  resolveSessionId: (paneCwd, panePid) => (panePid !== undefined ? (0, exports.detectClaudeSessionIdByPid)(panePid, paneCwd) : null)
1081
1194
  ?? (0, exports.detectClaudeSessionId)(paneCwd),
1082
- resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeSessionNameByPid)(panePid, paneCwd) : null,
1195
+ resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeProviderName)(panePid, paneCwd) : null,
1083
1196
  resolveTranscript: (paneCwd, panePid) => (0, exports.claudeTranscriptPath)(paneCwd, panePid),
1084
1197
  projectTranscript: transcript_tail_js_1.projectTranscriptEntries,
1085
1198
  // The pid match alone: the mtime fallback above can name another
@@ -1098,6 +1211,8 @@ const REMOTE_AGENT_TABLE = [
1098
1211
  resolveSessionName: exports.detectCodexSessionName,
1099
1212
  resolveTranscript: (paneCwd, panePid) => (0, exports.codexTranscriptPath)(paneCwd, panePid),
1100
1213
  projectTranscript: codex_transcript_js_1.projectCodexEntries,
1214
+ // Read only to tell a resumed conversation's run (no `resumeArgs`): the rollout a resume appends to.
1215
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.codexTranscriptPath)(paneCwd, panePid), panePid),
1101
1216
  },
1102
1217
  {
1103
1218
  // The pane binary is `cursor-agent`, Cursor's terminal TUI — NOT the
@@ -1118,6 +1233,7 @@ const REMOTE_AGENT_TABLE = [
1118
1233
  // limit pi has (newest file wins).
1119
1234
  resolveTranscript: (paneCwd, panePid) => (0, exports.geminiTranscriptPath)(paneCwd, panePid),
1120
1235
  projectTranscript: gemini_transcript_js_1.projectGeminiEntries,
1236
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.geminiTranscriptPath)(paneCwd, panePid), panePid),
1121
1237
  },
1122
1238
  {
1123
1239
  // `hermes` on PATH is a bash script that execs a venv Python, so the
@@ -1133,6 +1249,7 @@ const REMOTE_AGENT_TABLE = [
1133
1249
  // No `resolveSessionId` counterpart: the store's own id is a
1134
1250
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
1135
1251
  resolveSessionName: exports.detectHermesSessionName,
1252
+ resolveResumeId: exports.detectHermesResumeId,
1136
1253
  },
1137
1254
  {
1138
1255
  kind: 'pi',
@@ -1141,6 +1258,8 @@ const REMOTE_AGENT_TABLE = [
1141
1258
  subcommands: ['pi'],
1142
1259
  resolveTranscript: (paneCwd, panePid) => (0, exports.piTranscriptPath)(paneCwd, panePid),
1143
1260
  projectTranscript: pi_transcript_js_1.projectPiEntries,
1261
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.piTranscriptPath)(paneCwd, panePid), panePid),
1262
+ resolveSessionName: (paneCwd, panePid) => (0, exports.detectPiSessionName)(paneCwd, panePid),
1144
1263
  },
1145
1264
  {
1146
1265
  kind: 'opencode',
@@ -1150,7 +1269,11 @@ const REMOTE_AGENT_TABLE = [
1150
1269
  // Typed into a 2.0.24 TUI pane: `/exit` + Enter closed it.
1151
1270
  quitCommand: '/exit',
1152
1271
  resolveSessionId: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.id ?? null,
1153
- resolveSessionName: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.title ?? null,
1272
+ // opencode titles a session itself after the first exchange.
1273
+ resolveSessionName: (paneCwd, panePid) => {
1274
+ const title = (0, exports.detectOpencodeSession)(paneCwd, panePid)?.title;
1275
+ return title ? { name: title, source: 'auto' } : null;
1276
+ },
1154
1277
  resolveTranscript: (paneCwd, panePid) => (0, exports.opencodeTranscriptPath)(paneCwd, panePid),
1155
1278
  projectTranscript: opencode_transcript_js_1.projectOpencodeEntries,
1156
1279
  readTranscript: (path, prev) => (0, opencode_transcript_js_1.readOpencodeTranscript)(path, prev, { query: queryOpencodeTranscript }),
@@ -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;
@@ -25,6 +25,12 @@ const clip = (text, max) => {
25
25
  const last = cut.charCodeAt(cut.length - 1);
26
26
  return `${last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut}…`;
27
27
  };
28
+ const listedPrompt = (schedule, max) => {
29
+ const { prompt } = schedule;
30
+ const listed = prompt !== undefined && max > 0 ? clip(prompt, max) : undefined;
31
+ const locked = listed !== prompt || schedule.agentArgs !== undefined;
32
+ return { ...(listed === undefined ? {} : { prompt: listed }), ...(locked ? { promptLocked: true } : {}) };
33
+ };
28
34
  const isoOrNull = (ms) => {
29
35
  if (ms === null)
30
36
  return null;
@@ -47,9 +53,9 @@ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
47
53
  project: (0, node_path_1.basename)(schedule.cwd),
48
54
  session: schedule.session,
49
55
  when: schedule.when,
50
- ...(schedule.prompt !== undefined && promptChars > 0 ? { prompt: clip(schedule.prompt, promptChars) } : {}),
56
+ ...listedPrompt(schedule, promptChars),
51
57
  maxRuntimeMin: schedule.maxRuntimeMin,
52
- nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, Date.parse(schedule.createdAt))),
58
+ nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule))),
53
59
  ...(last && lastAt ? { last: { outcome: last.outcome, at: lastAt } } : {}),
54
60
  };
55
61
  });
@@ -144,9 +150,48 @@ const remove = (request, session) => {
144
150
  const removed = (0, schedule_js_1.removeSchedule)(id);
145
151
  return removed === 'invalid' ? refuse('schedule_invalid', 'not a schedule id') : { ok: true };
146
152
  };
153
+ const UPDATE_KEYS = new Set(['op', 'id', 'when', 'prompt', 'maxRuntimeMin']);
154
+ const update = (request, session, deps) => {
155
+ const extra = Object.keys(request).find((k) => !UPDATE_KEYS.has(k));
156
+ if (extra !== undefined)
157
+ return refuse('schedule_invalid', `an update carries ${extra}`);
158
+ const { id, when, prompt, maxRuntimeMin } = request;
159
+ if (typeof id !== 'string')
160
+ return refuse('schedule_invalid', 'an update needs an id');
161
+ const read = (0, schedule_js_1.listSchedules)();
162
+ if (read.incomplete)
163
+ return refuse('schedule_failed', 'the schedules could not be listed');
164
+ // As for a remove: the push names the schedule's own session, so it cannot change another one.
165
+ const target = read.schedules.find((schedule) => schedule.id === id);
166
+ if (!target || target.session !== session)
167
+ return refuse('schedule_invalid', 'no such schedule for that session');
168
+ // A prompt the list cut is left out by the app, so a missing one keeps the file's whole text.
169
+ // Arguments the computer gave (a permissions bypass, say) were chosen for its own prompt: the phone cannot put another under them.
170
+ if (prompt !== undefined && target.agentArgs !== undefined)
171
+ return refuse('schedule_invalid', 'a schedule with agent arguments takes its prompt from hwip schedule edit');
172
+ // As `hwip schedule edit`: a prompt of spaces would be typed as nothing.
173
+ if (typeof prompt === 'string' && prompt.trim() === '')
174
+ return refuse('schedule_invalid', 'the prompt is empty');
175
+ if (prompt !== undefined && !deps.canPrompt(target.agentKind))
176
+ return refuse('schedule_invalid', 'that agent cannot take a scheduled prompt');
177
+ // Unchecked here: `updateSchedule` validates the whole schedule they make before it writes.
178
+ const change = {};
179
+ if (when !== undefined)
180
+ change.when = when;
181
+ if (prompt !== undefined)
182
+ change.prompt = prompt;
183
+ if (maxRuntimeMin !== undefined)
184
+ change.maxRuntimeMin = maxRuntimeMin;
185
+ const updated = (0, schedule_js_1.updateSchedule)(id, change, deps.now());
186
+ if (updated.ok)
187
+ return { ok: true };
188
+ // Gone between the list and the write is the same as never there for this session.
189
+ return refuse(updated.failure === 'io' ? 'schedule_failed' : 'schedule_invalid', updated.error);
190
+ };
147
191
  /**
148
192
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
149
- * recorded session `session`, or `{ op: 'remove', id }` for the schedule whose session that is. Nothing here
193
+ * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
194
+ * schedule whose session that is. Nothing here
150
195
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
151
196
  */
152
197
  const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDeps) => {
@@ -155,6 +200,8 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
155
200
  try {
156
201
  if (request.op === 'create')
157
202
  return create(request, session, deps);
203
+ if (request.op === 'update')
204
+ return update(request, session, deps);
158
205
  if (request.op === 'remove')
159
206
  return remove(request, session);
160
207
  }
@@ -162,6 +209,6 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
162
209
  // A file that would not read or write is this machine's trouble, not a mistake in the request.
163
210
  return refuse('schedule_failed', err instanceof Error ? err.message : String(err));
164
211
  }
165
- return refuse('schedule_invalid', 'op is neither create nor remove');
212
+ return refuse('schedule_invalid', 'op is not create, update or remove');
166
213
  };
167
214
  exports.applyPhoneSchedule = applyPhoneSchedule;
@@ -142,7 +142,10 @@ const createScheduleRunner = (deps) => {
142
142
  for (const schedule of schedules) {
143
143
  // No record of a schedule in a damaged file may mean "nothing ran" or "the record was lost": start from now, so
144
144
  // neither a pile of old runs is counted as missed nor a run that already happened is started again.
145
- state[schedule.id] = previous[schedule.id] ?? { cursor: file.damaged ? tickNow : Date.parse(schedule.createdAt) };
145
+ // An edit restarts the count: the times before it belong to the schedule as it was.
146
+ const prior = previous[schedule.id];
147
+ const anchor = (0, schedule_js_1.scheduleAnchor)(schedule);
148
+ state[schedule.id] = { ...prior, cursor: Math.max(prior?.cursor ?? (file.damaged ? tickNow : anchor), anchor) };
146
149
  }
147
150
  // A state entry whose schedule file is gone is dropped — but only when the list is whole: a file that could not be
148
151
  // read this time is still there, and losing its cursor would repeat or miscount runs. A session that ended on its own is no longer running.
@@ -206,7 +209,7 @@ const createScheduleRunner = (deps) => {
206
209
  for (const schedule of schedules) {
207
210
  const st = state[schedule.id];
208
211
  const now = tickNow + (clock() - tickStart);
209
- const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, Date.parse(schedule.createdAt));
212
+ const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, (0, schedule_js_1.scheduleAnchor)(schedule));
210
213
  if (due.count === 0 || due.latest === null)
211
214
  continue;
212
215
  const runs = now - due.latest <= exports.GRACE_MS;