@hwip/cli 0.1.16 → 0.1.17

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 CHANGED
@@ -573,6 +573,17 @@ again picks up that run's conversation, not the newest one. If the name
573
573
  is live again by then, that run starts beside it under the next free name
574
574
  of the family (`<name>-2`, `<name>-3`, …) instead of being refused.
575
575
 
576
+ The phone can also start a **new** session in a project this machine has
577
+ run before: the same request with `fresh: true` starts the agent recorded
578
+ for that session in its directory, with no conversation picked up, under
579
+ the session's name or — when that runs — the next free name of its family.
580
+ The directory and the agent still come only from this machine's record, so
581
+ the phone cannot name either. The listener advertises `session-start-v1`
582
+ so the apps offer it only to a build that knows it. Ending a session from
583
+ the phone is `agent.session.exit.request`: `C-c`, the agent's own quit
584
+ command, then `C-d` — never a forced kill, so an agent that will not quit is
585
+ reported as still running.
586
+
576
587
  Two details worth knowing:
577
588
 
578
589
  - It fires at **user login**, not at boot. Without automatic login, the
@@ -1,3 +1,9 @@
1
+ /**
2
+ * This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
3
+ * session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
4
+ * offer it only to a computer that lists this.
5
+ */
6
+ export declare const SESSION_START_CAPABILITY = "session-start-v1";
1
7
  /** The one message both halves of presence ride; the server answers
2
8
  * `listener.presence.ack` or `listener.presence.error`. */
3
9
  export declare const LISTENER_PRESENCE = "listener.presence";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
3
+ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = exports.SESSION_START_CAPABILITY = void 0;
4
4
  /**
5
5
  * Per-device E2E (ADR-0007) — put this machine's public key on its device
6
6
  * record, so other devices can wrap pushes and files for it.
@@ -28,13 +28,20 @@ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
28
28
  * a file in chunks only for a device that lists `file-chunk-v1`, and one over
29
29
  * the relay's ceiling over LAN only to one that lists `lan-large-v1`; an app
30
30
  * offers `once` schedules and schedules into a session only to one that lists
31
- * `schedule-session-v1`. In one frame
31
+ * `schedule-session-v1`, and a new session in a project only to one that
32
+ * lists `session-start-v1`. In one frame
32
33
  * with the key, because a server from before `capabilities` answers a frame
33
34
  * that carries nothing else it knows with an error.
34
35
  */
35
36
  const file_crypto_js_1 = require("./file-crypto.js");
36
37
  const lan_receiver_js_1 = require("./lan-receiver.js");
37
38
  const schedule_js_1 = require("./schedule.js");
39
+ /**
40
+ * This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
41
+ * session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
42
+ * offer it only to a computer that lists this.
43
+ */
44
+ exports.SESSION_START_CAPABILITY = 'session-start-v1';
38
45
  /** The one message both halves of presence ride; the server answers
39
46
  * `listener.presence.ack` or `listener.presence.error`. */
40
47
  exports.LISTENER_PRESENCE = 'listener.presence';
@@ -45,7 +52,7 @@ const createDeviceKeyRegistration = (deps) => {
45
52
  const register = async () => {
46
53
  try {
47
54
  const publicKey = await deps.publicKey();
48
- const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY] } });
55
+ const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY, exports.SESSION_START_CAPABILITY] } });
49
56
  if (!sent)
50
57
  deps.log('device key: not registered — no connection right now, retried at the next open');
51
58
  return sent;
@@ -551,6 +551,12 @@ export interface SessionResumeRequest {
551
551
  * it reads as a run id.
552
552
  */
553
553
  runId?: string;
554
+ /**
555
+ * A new session in that session's project, not the session again (hwip docs/session-runs.md Q47): the recorded
556
+ * directory and agent, no conversation picked up, and beside the name when it runs. Read only when it is `true`.
557
+ * Advertised as `SESSION_START_CAPABILITY` (`device-presence.ts`) — an older listener would resume the recorded conversation instead.
558
+ */
559
+ fresh?: boolean;
554
560
  requestId?: string;
555
561
  }
556
562
  export interface SessionResumeResult {
package/dist/listener.js CHANGED
@@ -369,6 +369,12 @@ const RATE_LIMIT_TOKENS = 120;
369
369
  const RATE_LIMIT_WINDOW_MS = 60_000;
370
370
  /** What one command submit costs, keeping its ceiling at the old 30/min. */
371
371
  const SUBMIT_COST = 4;
372
+ /**
373
+ * What a fresh start costs on its family's own bucket (`start:<family base>`): three new agents a minute. Each one is a
374
+ * process left running, and every name of the family leads to the same bucket, so a session budget of 30 a minute
375
+ * cannot fill the family's 20 names in one burst.
376
+ */
377
+ const FRESH_START_COST = RATE_LIMIT_TOKENS / 3;
372
378
  // Shells are refused: a shell prompt + send-keys = arbitrary command exec.
373
379
  const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'pwsh']);
374
380
  // Auth-failure close codes: retrying with the same bad credentials hammers
@@ -1546,13 +1552,16 @@ const handleSessionResumeRequest = (req, send) => {
1546
1552
  // the family `hwip cc` would pick (Q41), counted from the family base so a
1547
1553
  // busy `<base>-2` leads to `<base>-3`. A request without a run id is an
1548
1554
  // older app that only knows the name, and still gets the refusal it renders.
1555
+ // A fresh start asks for a new session, so a live name is no refusal either, and no conversation is picked up —
1556
+ // the live one holding the recorded conversation included.
1557
+ const fresh = req.fresh === true;
1549
1558
  const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
1550
1559
  const target = !live.has(sessionName)
1551
1560
  ? sessionName
1552
- : runId !== undefined
1561
+ : runId !== undefined || fresh
1553
1562
  ? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
1554
1563
  : undefined;
1555
- if (!target || conversationRunning(known, live))
1564
+ if (!target || (!fresh && conversationRunning(known, live)))
1556
1565
  return reply({ error: 'already_running' });
1557
1566
  if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
1558
1567
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
@@ -1563,6 +1572,11 @@ const handleSessionResumeRequest = (req, send) => {
1563
1572
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: no binary for agent kind ${known.agentKind}`);
1564
1573
  return reply({ error: 'unknown_agent' });
1565
1574
  }
1575
+ // A fresh start leaves one more process running each time, so the family pays on a bucket of its own as well —
1576
+ // charged only here, where it starts one: a refusal above says why, not that the budget ran out.
1577
+ if (fresh && !(0, exports.checkRateLimit)(`start:${familyBase(sessionName)}`, undefined, FRESH_START_COST)) {
1578
+ return reply({ error: 'rate_limited' });
1579
+ }
1566
1580
  // argv, never a shell string: the binary comes from the agent table and the
1567
1581
  // directory from our own record, and neither is concatenated into anything
1568
1582
  // a shell would re-parse. A recorded label means the session carried
@@ -1572,7 +1586,7 @@ const handleSessionResumeRequest = (req, send) => {
1572
1586
  const sidecar = known.label && known.project
1573
1587
  ? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
1574
1588
  : [];
1575
- const conversation = resumableConversation(agent, known);
1589
+ const conversation = fresh ? null : resumableConversation(agent, known);
1576
1590
  // tmux gives the new pane this client's PATH. A login service's PATH has none
1577
1591
  // of the agent directories, so they go on as schedule launches add them —
1578
1592
  // otherwise the agent is not found and the session ends as it starts.
@@ -1583,7 +1597,7 @@ const handleSessionResumeRequest = (req, send) => {
1583
1597
  }
1584
1598
  const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
1585
1599
  const beside = target === sessionName ? '' : ` as ${target}`;
1586
- (0, listener_log_js_1.log)(`⟲ resume ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1600
+ (0, listener_log_js_1.log)(`${fresh ? '+ start' : '⟲ resume'} ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1587
1601
  return reply({ resumed: true, sessionName: target });
1588
1602
  };
1589
1603
  exports.handleSessionResumeRequest = handleSessionResumeRequest;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",