@hwip/cli 0.1.20 → 0.1.22

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
@@ -435,9 +435,12 @@ block here.
435
435
  real terminal: `cd` and `export` stick, `sudo`/`ssh` prompts work, and a
436
436
  long command does not tie up an agent. `--label <x>` names it
437
437
  `hwip-<project>-<x>`; `--detach` creates it without attaching. Away from
438
- the desk, open one from the phone through any running Claude Code
439
- session by sending `!hwip sh --detach` (Claude Code's `!` bash mode —
440
- see Defense item 7). Inside tmux it never attaches; it prints a
438
+ the desk, open one from the phone's **New session → Terminal**: the
439
+ listener runs the same `hwip sh --detach` in the directory the chosen
440
+ project last ran in (`session-shell-v1`; the phone names a session it saw,
441
+ never a directory). Through any running Claude Code session, sending
442
+ `!hwip sh --detach` does the same (Claude Code's `!` bash mode — see
443
+ Defense item 7). Inside tmux it never attaches; it prints a
441
444
  `tmux switch-client -t <session>` line instead. Running it again for a
442
445
  session it opened reattaches, and a session of that name it did not open
443
446
  is refused.
@@ -980,8 +983,11 @@ into a shell-adjacent pane). The defense is layered:
980
983
  What the marker does not stop: anything running as your user on this
981
984
  machine can set `@hwip_shell` on a session, or run `hwip sh` itself — and
982
985
  so can the phone, while a Claude Code session (`!tmux set-option …`) or a
983
- marked shell is open to it. It keeps the phone from choosing panes only
984
- when neither is.
986
+ marked shell is open to it. And the phone can always open a marked shell of
987
+ its own: New session → Terminal starts `hwip-<project>-sh` in any project
988
+ this machine recorded. The marker keeps the phone from typing into panes
989
+ you opened yourself (an agent's, a plain shell's), not from running
990
+ commands on this machine.
985
991
  And no layer authenticates the sender: whoever holds your API key, or
986
992
  controls the backend, can type into every marked shell and, through `!`,
987
993
  every Claude Code session.
@@ -4,6 +4,8 @@
4
4
  * offer it only to a computer that lists this.
5
5
  */
6
6
  export declare const SESSION_START_CAPABILITY = "session-start-v1";
7
+ /** A fresh start that names agent kind `shell` opens the project's `hwip sh` Terminal (listener `startShellBeside`). */
8
+ export declare const SESSION_SHELL_CAPABILITY = "session-shell-v1";
7
9
  /** The one message both halves of presence ride; the server answers
8
10
  * `listener.presence.ack` or `listener.presence.error`. */
9
11
  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 = exports.SESSION_START_CAPABILITY = void 0;
3
+ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = exports.SESSION_SHELL_CAPABILITY = 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.
@@ -42,6 +42,8 @@ const schedule_js_1 = require("./schedule.js");
42
42
  * offer it only to a computer that lists this.
43
43
  */
44
44
  exports.SESSION_START_CAPABILITY = 'session-start-v1';
45
+ /** A fresh start that names agent kind `shell` opens the project's `hwip sh` Terminal (listener `startShellBeside`). */
46
+ exports.SESSION_SHELL_CAPABILITY = 'session-shell-v1';
45
47
  /** The one message both halves of presence ride; the server answers
46
48
  * `listener.presence.ack` or `listener.presence.error`. */
47
49
  exports.LISTENER_PRESENCE = 'listener.presence';
@@ -52,7 +54,7 @@ const createDeviceKeyRegistration = (deps) => {
52
54
  const register = async () => {
53
55
  try {
54
56
  const publicKey = await deps.publicKey();
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] } });
57
+ 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, exports.SESSION_SHELL_CAPABILITY] } });
56
58
  if (!sent)
57
59
  deps.log('device key: not registered — no connection right now, retried at the next open');
58
60
  return sent;
@@ -549,6 +549,8 @@ export interface SessionResumeResult {
549
549
  error?: string;
550
550
  at: string;
551
551
  }
552
+ /** The `agentKind` a fresh start names to ask for a Terminal (`hwip sh`) instead of an agent (`SESSION_SHELL_CAPABILITY`). */
553
+ export declare const SHELL_START_KIND = "shell";
552
554
  /**
553
555
  * Start a remembered session again, detached, and tell the phone what happened.
554
556
  * Returns true when the message was ours to answer.
package/dist/listener.js CHANGED
@@ -26,8 +26,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.continueRuns = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.resetPaneFitClaims = exports.tmuxTargetFor = exports.subagentTranscriptFor = exports.recordTargets = exports.recordSubagentTranscripts = exports.isSubagentSessionName = exports.detectRemoteAgent = exports.sessionLiveness = exports.sessionExists = exports.liveSessionNames = exports.tmuxArgs = exports.silenceListenerLog = exports.invalidateTmuxSocketCache = exports.wheelInput = exports.wheelEvents = exports.WHEEL_STATE_FORMAT = exports.resolveWheel = exports.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.withRunSeqs = exports.staleConnectionAction = exports.askedToReconnect = exports.MAX_STALE_CONNECTION_DROPS = exports.createReportAckWatch = exports.REPORT_ACK_TIMEOUT_MS = exports.SESSION_REPORT_HEARTBEAT_MS = exports.SESSION_REPORT_INTERVAL_MS = exports.heartbeatVerdict = exports.pingDue = exports.PING_IDLE_MS = void 0;
29
- exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = void 0;
30
- exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = void 0;
29
+ exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.SHELL_START_KIND = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = void 0;
30
+ exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = void 0;
31
31
  const child_process_1 = require("child_process");
32
32
  const crypto_1 = require("crypto");
33
33
  const fs_1 = require("fs");
@@ -1466,6 +1466,38 @@ const conversationRunning = (known, live) => {
1466
1466
  return current?.agentKind === known.agentKind && current.agentSessionId === id;
1467
1467
  });
1468
1468
  };
1469
+ /** The `agentKind` a fresh start names to ask for a Terminal (`hwip sh`) instead of an agent (`SESSION_SHELL_CAPABILITY`). */
1470
+ exports.SHELL_START_KIND = 'shell';
1471
+ /**
1472
+ * A Terminal in `sessionName`'s project (hwip docs/session-runs.md Q50): `hwip sh --detach` in the directory this machine recorded for it — the login
1473
+ * shell the phone may type into (`@hwip_shell`), audited as every shell input is. The phone names a session it saw, never
1474
+ * a directory, as for an agent; the newest run of the name gives the directory, whichever agent ran it.
1475
+ */
1476
+ const startShellBeside = (sessionName, reply) => {
1477
+ const known = (0, session_registry_js_1.recallSession)(sessionName, Date.now());
1478
+ if (!known) {
1479
+ (0, listener_log_js_1.log)(`✗ shell beside ${sessionName}: never seen on this machine`);
1480
+ return reply({ error: 'unknown_session' });
1481
+ }
1482
+ if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
1483
+ (0, listener_log_js_1.log)(`✗ shell beside ${sessionName}: ${known.cwd} is gone`);
1484
+ return reply({ error: 'missing_directory' });
1485
+ }
1486
+ // One shell per project: a repeat tap reopens it, so this bucket only stops a flood of starts.
1487
+ if (!(0, exports.checkRateLimit)(`start:shell:${known.cwd}`, undefined, FRESH_START_COST))
1488
+ return reply({ error: 'rate_limited' });
1489
+ const shell = process.env.SHELL || (0, os_1.userInfo)().shell || '/bin/sh';
1490
+ const started = (0, wrapper_js_1.startShellSession)(known.cwd, shell, (args) => {
1491
+ const r = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(args), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1492
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
1493
+ });
1494
+ if ('refused' in started) {
1495
+ (0, listener_log_js_1.log)(`✗ shell beside ${sessionName}: ${started.refused}`);
1496
+ return reply({ error: started.refused });
1497
+ }
1498
+ (0, listener_log_js_1.log)(`+ shell ${started.session} in ${known.cwd}`);
1499
+ return reply({ resumed: true, sessionName: started.session });
1500
+ };
1469
1501
  /**
1470
1502
  * Start a remembered session again, detached, and tell the phone what happened.
1471
1503
  * Returns true when the message was ours to answer.
@@ -1505,6 +1537,8 @@ const handleSessionResumeRequest = (req, send) => {
1505
1537
  // through whichever path is cheaper.
1506
1538
  if (!(0, exports.checkRateLimit)(sessionName, undefined, SUBMIT_COST))
1507
1539
  return reply({ error: 'rate_limited' });
1540
+ if (req.fresh === true && req.agentKind === exports.SHELL_START_KIND)
1541
+ return startShellBeside(sessionName, reply);
1508
1542
  // A named agent is answered with that run or not at all. Falling back to the
1509
1543
  // newest run would start claude for a phone that asked for pi — silently,
1510
1544
  // which is the exact failure this path exists to remove. A phone that names
@@ -5285,7 +5319,7 @@ const streamSession = (wsUrl, apiKey) => {
5285
5319
  // already exists rather than a request of their own: the phone is
5286
5320
  // already reading this device record (recent-projects.ts).
5287
5321
  const reported = (0, exports.withRunSeqs)(sessions);
5288
- const recent = (0, recent_projects_js_1.recentProjectsToReport)();
5322
+ const recent = (0, recent_projects_js_1.recentProjectsToReport)(new Set(sessions.map((s) => s.name)));
5289
5323
  const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${recent ? JSON.stringify(recent) : '-'}`;
5290
5324
  if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
5291
5325
  return;
@@ -17,15 +17,21 @@ export interface RecentProject {
17
17
  }
18
18
  /** A short list: the projects a person works in lately, not every folder the machine ever ran one in. */
19
19
  export declare const RECENT_PROJECTS_MAX = 12;
20
- /** Each project's agents, from the newest session of each, in the order `known` gives (newest first). */
20
+ /**
21
+ * Each project's agents, from the newest session of each, in the order `known` gives (newest first).
22
+ *
23
+ * A project with a session in `live` is listed without counting toward the 12: a running session's entry is always the
24
+ * newest, so twelve running projects would leave no room for one the machine ran before (hwip docs/session-runs.md Q50).
25
+ * Its entries still go out — the agents it ran before and does not run now are only here.
26
+ */
21
27
  export declare const recentProjects: (known: readonly {
22
28
  name: string;
23
29
  agentKind: string;
24
30
  project?: string;
25
- }[]) => RecentProject[];
31
+ }[], live?: ReadonlySet<string>) => RecentProject[];
26
32
  /**
27
- * The list for this report, or undefined when the registry would not read — an empty list would
33
+ * The list for this report beside `live`, the sessions it reports running, or undefined when the registry would not read — an empty list would
28
34
  * clear what the server holds, and the phone would lose every project to start in.
29
35
  */
30
- export declare const recentProjectsToReport: (now?: number) => RecentProject[] | undefined;
36
+ export declare const recentProjectsToReport: (live: ReadonlySet<string>, now?: number) => RecentProject[] | undefined;
31
37
  //# sourceMappingURL=recent-projects.d.ts.map
@@ -18,19 +18,27 @@ exports.RECENT_PROJECTS_MAX = 12;
18
18
  const isSubagent = (name) => /\.\d+$/.test(name);
19
19
  /** A session a schedule started: starting from it would copy the scheduled run, not the project. */
20
20
  const isScheduled = (name) => name.includes('-sched-');
21
- /** Each project's agents, from the newest session of each, in the order `known` gives (newest first). */
22
- const recentProjects = (known) => {
23
- const projects = new Set();
21
+ /**
22
+ * Each project's agents, from the newest session of each, in the order `known` gives (newest first).
23
+ *
24
+ * A project with a session in `live` is listed without counting toward the 12: a running session's entry is always the
25
+ * newest, so twelve running projects would leave no room for one the machine ran before (hwip docs/session-runs.md Q50).
26
+ * Its entries still go out — the agents it ran before and does not run now are only here.
27
+ */
28
+ const recentProjects = (known, live = new Set()) => {
29
+ const projectOf = (e) => e.project || e.name;
30
+ const eligible = known.filter((e) => !isSubagent(e.name) && !isScheduled(e.name));
31
+ // As the phone counts running: a scheduled run or a subagent is no running session of the project there.
32
+ const running = new Set(eligible.filter((e) => live.has(e.name)).map(projectOf));
33
+ const past = new Set();
24
34
  const seen = new Set();
25
35
  const listed = [];
26
- for (const e of known) {
27
- if (isSubagent(e.name) || isScheduled(e.name))
28
- continue;
29
- const project = e.project || e.name;
30
- if (!projects.has(project)) {
31
- if (projects.size >= exports.RECENT_PROJECTS_MAX)
36
+ for (const e of eligible) {
37
+ const project = projectOf(e);
38
+ if (!running.has(project) && !past.has(project)) {
39
+ if (past.size >= exports.RECENT_PROJECTS_MAX)
32
40
  continue;
33
- projects.add(project);
41
+ past.add(project);
34
42
  }
35
43
  const key = `${project}\u0000${e.agentKind}`;
36
44
  if (seen.has(key))
@@ -42,8 +50,8 @@ const recentProjects = (known) => {
42
50
  };
43
51
  exports.recentProjects = recentProjects;
44
52
  /**
45
- * The list for this report, or undefined when the registry would not read — an empty list would
53
+ * The list for this report beside `live`, the sessions it reports running, or undefined when the registry would not read — an empty list would
46
54
  * clear what the server holds, and the phone would lose every project to start in.
47
55
  */
48
- const recentProjectsToReport = (now = Date.now()) => (0, session_registry_js_1.registryUnreadable)() ? undefined : (0, exports.recentProjects)((0, session_registry_js_1.knownSessions)(now));
56
+ const recentProjectsToReport = (live, now = Date.now()) => ((0, session_registry_js_1.registryUnreadable)() ? undefined : (0, exports.recentProjects)((0, session_registry_js_1.knownSessions)(now), live));
49
57
  exports.recentProjectsToReport = recentProjectsToReport;
@@ -4,8 +4,12 @@
4
4
  * the account has signed in. Kept apart so the recorder's tests need none of it.
5
5
  */
6
6
  import { type OutboxChunk, type RecorderRecipient, type SealedChunk, type UploadOutcome } from './turn-recorder.js';
7
- /** The turns route has its own per-minute allowance on the server (240); chunks keep to 30, well inside it. */
8
- export declare const RECORDER_UPLOADS_PER_MINUTE = 30;
7
+ /**
8
+ * The turns route has its own per-minute allowance on the server (240, per account); chunks keep to half
9
+ * of it, so a second machine still has room. 30 was too few: on 2026-11-10 nineteen live sessions made
10
+ * chunks about as fast as 30 a minute let them out, and a backlog of ~100 never drained.
11
+ */
12
+ export declare const RECORDER_UPLOADS_PER_MINUTE = 120;
9
13
  /** Seal one plaintext body (`{sessionKey, seq, events}`) as a push-v1 envelope for the recipients. */
10
14
  export declare const sealChunk: (plaintext: string, recipients: RecorderRecipient[]) => Promise<SealedChunk>;
11
15
  export interface AccountRecipientsDeps {
@@ -14,8 +14,12 @@ const subscriber_trust_js_1 = require("./subscriber-trust.js");
14
14
  const trust_pins_js_1 = require("./trust-pins.js");
15
15
  const turn_recorder_js_1 = require("./turn-recorder.js");
16
16
  const UPLOAD_TIMEOUT_MS = 15_000;
17
- /** The turns route has its own per-minute allowance on the server (240); chunks keep to 30, well inside it. */
18
- exports.RECORDER_UPLOADS_PER_MINUTE = 30;
17
+ /**
18
+ * The turns route has its own per-minute allowance on the server (240, per account); chunks keep to half
19
+ * of it, so a second machine still has room. 30 was too few: on 2026-11-10 nineteen live sessions made
20
+ * chunks about as fast as 30 a minute let them out, and a backlog of ~100 never drained.
21
+ */
22
+ exports.RECORDER_UPLOADS_PER_MINUTE = 120;
19
23
  /** How long a session waits after a 403 before the same chunk is tried again. */
20
24
  const RECORDER_KEY_RETRY_MS = 5 * 60_000;
21
25
  /** After this many 403s in a row the chunk is dropped: the key it was sealed with is not the one on file, and will not be. */
package/dist/wrapper.d.ts CHANGED
@@ -210,7 +210,7 @@ export declare const handleAgentSession: (agent: RemoteAgent, extra?: string[],
210
210
  /** What tmux holds under a `hwip sh` session name. */
211
211
  export type ShellSessionState = 'none' | 'marked' | 'unmarked';
212
212
  /** A tmux call, injectable for tests. */
213
- type RunTmux = (args: string[]) => {
213
+ export type RunTmux = (args: string[]) => {
214
214
  status: number | null;
215
215
  stdout: string;
216
216
  stderr?: string;
@@ -258,6 +258,16 @@ export declare const shellTargets: (shell: string, opts: AgentLaunchOptions, inT
258
258
  create: SpawnTarget;
259
259
  attach: SpawnTarget | null;
260
260
  };
261
+ /**
262
+ * The phone's Terminal start (hwip docs/session-runs.md Q50): `hwip sh --detach` run in `projectDir` — the same
263
+ * `hwip-<project>-sh` login shell, marked so the phone may type into it. One already open under that name is the answer
264
+ * as it is; a session of that name `hwip sh` did not mark is refused, never taken over.
265
+ */
266
+ export declare const startShellSession: (projectDir: string, shell: string, run?: RunTmux) => {
267
+ session: string;
268
+ } | {
269
+ refused: "already_running" | "start_failed";
270
+ };
261
271
  /**
262
272
  * `hwip sh [--label x] [--detach]` — open (or reattach) `hwip-<project>-sh`
263
273
  * running the login shell, marked so the phone may type into it. The label
package/dist/wrapper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.agentLaunchArgs = exports.agentLaunchEnv = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
3
+ exports.handleShellSession = exports.startShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.agentLaunchArgs = exports.agentLaunchEnv = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
4
4
  /**
5
5
  * `hwip cc` / `hwip codex` / `hwip gemini` — spawn an agent inside a named
6
6
  * tmux session so the resident listener (`hwip listener`) can address it
@@ -736,7 +736,7 @@ exports.readShellSessionState = readShellSessionState;
736
736
  * run is a detached one, as `--detach` is.
737
737
  */
738
738
  const shellTargets = (shell, opts, inTmux = !!process.env.TMUX) => {
739
- const create = (0, exports.targetForAgent)(shell, ['-l'], 'shell', { label: opts.label ?? 'sh', detach: true });
739
+ const create = (0, exports.targetForAgent)(shell, ['-l'], 'shell', { projectDir: opts.projectDir, label: opts.label ?? 'sh', detach: true });
740
740
  const session = create.session ?? '';
741
741
  // `attach-session`, never `new -A`: a session that died since the probe
742
742
  // must not come back as a fresh, unmarked one.
@@ -744,6 +744,22 @@ const shellTargets = (shell, opts, inTmux = !!process.env.TMUX) => {
744
744
  return { create, attach: opts.detach || inTmux ? null : attach };
745
745
  };
746
746
  exports.shellTargets = shellTargets;
747
+ /**
748
+ * The phone's Terminal start (hwip docs/session-runs.md Q50): `hwip sh --detach` run in `projectDir` — the same
749
+ * `hwip-<project>-sh` login shell, marked so the phone may type into it. One already open under that name is the answer
750
+ * as it is; a session of that name `hwip sh` did not mark is refused, never taken over.
751
+ */
752
+ const startShellSession = (projectDir, shell, run = runTmux) => {
753
+ const { create } = (0, exports.shellTargets)(shell, { projectDir, detach: true }, true);
754
+ const session = create.session ?? '';
755
+ const plan = (0, exports.planShellSession)(create, null, (0, exports.readShellSessionState)(session, run));
756
+ if (plan.kind === 'refuse')
757
+ return { refused: 'already_running' };
758
+ if (plan.kind === 'create' && run(plan.args).status !== 0)
759
+ return { refused: 'start_failed' };
760
+ return { session };
761
+ };
762
+ exports.startShellSession = startShellSession;
747
763
  /**
748
764
  * `hwip sh [--label x] [--detach]` — open (or reattach) `hwip-<project>-sh`
749
765
  * running the login shell, marked so the phone may type into it. The label
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",