@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/README.md +27 -12
- package/dist/agent-dirs.d.ts +15 -0
- package/dist/agent-dirs.js +76 -0
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +15 -4
- package/dist/blocked-prompt.js +172 -14
- package/dist/listener.d.ts +33 -0
- package/dist/listener.js +76 -19
- package/dist/mcp-command.d.ts +1 -1
- package/dist/remote-agents.d.ts +3 -0
- package/dist/remote-agents.js +40 -7
- package/dist/schedule-command.d.ts +11 -1
- package/dist/schedule-command.js +113 -4
- package/dist/schedule-launch.d.ts +3 -1
- package/dist/schedule-launch.js +4 -1
- package/dist/schedule-remote.d.ts +7 -1
- package/dist/schedule-remote.js +51 -4
- package/dist/schedule-runner.js +5 -2
- package/dist/schedule.d.ts +31 -0
- package/dist/schedule.js +45 -7
- package/dist/session-registry.d.ts +20 -3
- package/dist/session-registry.js +27 -4
- package/dist/usage.js +4 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +14 -3
- package/package.json +1 -1
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
|
-
|
|
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',
|
|
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
|
-
|
|
1459
|
-
|
|
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' &&
|
|
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
|
|
1754
|
-
// view stops, and stopping is not a failure.
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1773
|
-
|
|
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.
|
package/dist/mcp-command.d.ts
CHANGED
package/dist/remote-agents.d.ts
CHANGED
|
@@ -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";
|
package/dist/remote-agents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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;
|
package/dist/schedule-command.js
CHANGED
|
@@ -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(),
|
|
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,
|
|
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
|
package/dist/schedule-launch.js
CHANGED
|
@@ -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`,
|
|
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;
|