session-peer 0.1.0-preview.0 → 0.1.0

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/RELEASING.md CHANGED
@@ -1,153 +1,118 @@
1
- # Releasing session-peer previews
2
-
3
- The package is publication-ready (`private:false`), not automatically published.
4
- Merging, pushing, tagging or publishing a GitHub release does not run publish.yml.
5
- Only an explicitly approved manual dispatch can upload. Python session-peer and
6
- its Relay release train remain independent.
7
-
8
- ## Before the first dispatch
9
-
10
- 1. Merge the reviewed preparation PR and require main's exact-commit `cli` CI
11
- (`release gate`) to pass. Match versions in package.json, lockfile,
12
- src/protocol.ts and four READMEs. Only `X.Y.Z-preview.N` versions and the
13
- `preview` dist-tag are supported; stable publication needs another review.
14
- 2. In GitHub Settings → Environments, create **npm** with at least one required
15
- reviewer. Restrict deployments to protected main. A solo maintainer may also
16
- be the dispatcher: this is explicit approval, not two-person separation.
17
- Do not bypass protection. Preflight rejects missing environments or reviewer
18
- rules. This PR does not create or relax environment protection.
19
- 3. Confirm npm account 2FA, package-name ownership and creation permissions.
20
- A registry 404 does not reserve the name. Review VALIDATION.md and architecture
21
- coverage; historical real-TUI ACKs predate the command rename. Do not claim
22
- full native-agent architecture coverage from CI fixtures.
23
- 4. For the initial **0.1.0-preview.0 only**, repository secret **NPM_TOKEN** supplies
24
- bootstrap authentication. Use a short-expiry, least-privilege granular token
25
- permitting this new package. Noninteractive direct publication requires a token
26
- allowed to bypass npm's 2FA prompt. Secret existence does not establish its
27
- permissions. Do not weaken account policy automatically: stop if unsuitable
28
- and use an approved local interactive first publish instead.
29
- Never put credentials in files, logs, issues or chat. Builds/tests/packing
30
- never receive this secret.
31
- 5. Obtain final approval for the exact main commit, version and mode. A merged
32
- preparation PR is not publication approval. Optional GitHub draft release
33
- notes must target that same commit; the workflow creates no tag or release.
34
- Runtime changes require separate reviewed evidence.
35
-
36
- ## First package: bootstrap-token
37
-
38
- ### Initial failed attempt (2026-09-27)
39
-
40
- [Run 36308424358](https://github.com/abruption/session-peer-ts/actions/runs/36308424358)
41
- attempted the old npm name `session-peer-ts@0.1.0-preview.0`. Build and artifact
42
- checks passed, but the registry refused the PUT with HTTP 403 / E_STAGE_REQUIRED:
43
- the supplied token could only stage, and the package did not exist. A Sigstore
44
- provenance entry was created before that rejection; it is not proof of npm
45
- publication. Both npm names returned 404 during the subsequent read-only check.
46
-
47
- The intended npm name is now **session-peer**; the GitHub repository remains
48
- **abruption/session-peer-ts**. Renaming does not fix the authentication restriction.
49
- Do not rerun the old workflow or switch it to staging: a new package cannot be
50
- created by staging. Before an approved new attempt, the owner must resolve the
51
- initial direct-publication authorization or perform an approved interactive
52
- first publish with 2FA. Do not disable 2FA, automatically replace credentials or
53
- assume changing a token option bypasses current npm policy. The stored secret's
54
- value and settings were not inspected. This PR does not reattempt publication.
55
-
56
- ### Inputs after authentication is resolved
57
-
58
- Actions → **npm preview publication** → Run workflow → **main**:
59
-
60
- - mode: `bootstrap-token`
61
- - version: `0.1.0-preview.0`
62
- - confirmation: `session-peer@0.1.0-preview.0 bootstrap-token`
63
-
64
- Preflight requires current main SHA, successful exact-commit main CI and the
65
- protected npm environment. Bootstrap refuses **any existing registry package**
66
- under this name. The prepare job tests without publication credentials, packs
67
- twice, compares hashes, checks the allowlist and tests installation/uninstallation
68
- of the exact tarball. Its retained artifact includes tarball and manifest.json
69
- (commit, SHA-256 and SHA-512 integrity). Actions are pinned by commit; release
70
- builds use Node 24/npm 11.15.0 without dependency caches or lifecycle scripts.
71
-
72
- After environment approval, a fresh job downloads the artifact without rebuilding,
73
- rechecks main/CI/registry and validates its contents. Only one `npm publish`
74
- step receives NPM_TOKEN (`--tag preview --access public --provenance
75
- --ignore-scripts`). OIDC permission is also granted for provenance. Upload
76
- network retries are disabled. Do not rerun failed or uncertain publication.
77
-
78
- Verification compares registry integrity and preview tag, rejects accidental
79
- latest promotion, requires registry attestation metadata, runs `npm audit
80
- signatures`, and fresh-installs/uninstalls the published version. Only a passed
81
- verification completes bootstrap release; a successful upload alone does not.
82
- Bounded read-only propagation checks are not upload retries.
83
-
84
- ## Register npm Trusted Publisher after the first release
85
-
86
- npmjs.com → package **session-peer** → Settings → Trusted publishing:
87
-
88
- | Field | Value |
89
- | --- | --- |
90
- | Provider | GitHub Actions |
91
- | Organization or user | `abruption` |
92
- | Repository | `session-peer-ts` |
93
- | Workflow filename | `publish.yml` (no directory prefix) |
94
- | Environment name | `npm` |
95
- | Allowed actions | Stage-only: `npm stage publish`, not direct `npm publish` |
96
-
97
- The workflow must exist on main. npm does not validate the connection on save;
98
- validate it with the next authorized staged version. GitHub-hosted runners are
99
- required. Public GitHub + public npm OIDC publishing provides provenance.
100
-
101
- After verifying bootstrap and configuring trust, revoke its token in npm and
102
- remove GitHub NPM_TOKEN. Set npm publishing access to **Require two-factor
103
- authentication and disallow tokens**. These account changes need owner approval
104
- and are not performed by this workflow. trusted-stage never references NPM_TOKEN;
105
- an OIDC failure must not cause token fallback. Remove the bootstrap branch in a
106
- follow-up PR.
107
-
108
- ## Later previews: trusted-stage
109
-
110
- Prepare a new immutable preview version through a PR. Dispatch main with mode
111
- `trusted-stage`, its exact version and confirmation
112
- `session-peer@<version> trusted-stage`. The same gates apply. The package must
113
- already exist; published versions are refused. One `npm stage publish` runs
114
- with OIDC and the preview tag.
115
-
116
- **A successful staged run is pending approval, not a public release.** Record its
117
- stage ID from npm output. Review its tarball against the retained manifest and
118
- approve with 2FA in npmjs.com or interactive `npm stage approve`. OIDC cannot
119
- approve, list or view stages. Public registry metadata cannot reveal pending
120
- stages, so collisions fail at the registry and need manual inspection, not retries.
121
-
122
- After approval, check out the exact source commit and download that run artifact
123
- into `release-dist/`. With Node 24 and npm 11.15.0:
1
+ # Releasing session-peer
2
+
3
+ Publication is manual. Merging, pushing, tagging or creating a GitHub release
4
+ never dispatches `publish.yml`. Python session-peer and its Relay release train
5
+ remain independent. The npm Trusted Publisher for `abruption/session-peer-ts`,
6
+ `publish.yml`, environment `npm` allows `npm stage publish` only. The workflow
7
+ uses OIDC and no npm token. The protected GitHub environment requires a reviewer
8
+ and permits deployments from `main` only.
9
+
10
+ ## Verified preview and stable baseline — 2026-09-27
11
+
12
+ `session-peer@0.1.0-preview.1` was staged from main commit
13
+ `f65d3ee99cc26ff4a267aef84e31a308d034ac80` in
14
+ [run 36313884100](https://github.com/abruption/session-peer-ts/actions/runs/36313884100)
15
+ and published after a separate 2FA approval. The staged tarball matched the
16
+ retained run artifact byte for byte. Public verification passed registry
17
+ integrity, provenance metadata, `npm audit signatures`, fresh installation and
18
+ uninstallation. The artifact SHA-256 was
19
+ `d154beac93893b60b0695e5a6296e185beb8dbd05188ffe04befb74d29966110`;
20
+ registry integrity is
21
+ `sha512-h4SMvrQ/LWA9osd4EHIs9rSTqv1u+S3MXQAmn+yG/gZ9+7NwYMutq+Oa5K/0ggIq61Wfwdh11Cv+5YdEffiNMA==`.
22
+ The `preview` tag points to preview.1. The existing `latest` tag still points
23
+ to preview.0. No stable version has been published by this preparation PR.
24
+
25
+ ## Prepare 0.1.0 stable
26
+
27
+ 1. Merge the reviewed 0.1.0 preparation PR. Require successful `cli` CI on the
28
+ exact merged `main` commit, including the release gate and Windows Node 22/24.
29
+ Match `0.1.0` in package.json, lockfile, src/protocol.ts and all four READMEs.
30
+ Review [VALIDATION.md](VALIDATION.md): fixture CI and the historical real-TUI
31
+ ACKs have different scope. The stable candidate changes the version handshake
32
+ and release controls, not native delivery behavior.
33
+ 2. Confirm the `npm` environment still has a required reviewer and permits only
34
+ protected main. Confirm npm account 2FA, package ownership and the Trusted
35
+ Publisher's stage-only mapping above. Do not bypass environment protection.
36
+ 3. Read the public registry. `0.1.0` must be absent; `preview` must still point
37
+ to preview.1 and `latest` to preview.0. The published preview.1 integrity and
38
+ provenance must match the verified baseline above. Inspect pending stages with
39
+ `npm stage list session-peer` through an authenticated owner session;
40
+ public registry metadata cannot reveal them.
41
+ 4. Obtain final approval for the **exact main SHA**, `0.1.0` version and
42
+ `stable-stage` mode. A merged PR is not publication approval. The approval
43
+ must also cover promotion of npm `latest` to `0.1.0` when the stage is
44
+ approved. GitHub tag, release and fleet installation need separate decisions.
45
+
46
+ Dispatch Actions → **npm staged publication** → Run workflow → **main**:
47
+
48
+ - mode: `stable-stage`
49
+ - version: `0.1.0`
50
+ - confirmation: `session-peer@0.1.0 stable-stage`
51
+
52
+ The prepare job requires exact-main CI and the protected npm environment. It
53
+ checks the registry baseline, tests on Node 24/npm 11.15.0 without publish
54
+ credentials, packs twice, compares hashes, checks the allowlist and installs and
55
+ uninstalls the exact tarball in isolation. The retained `npm-release-<SHA>`
56
+ artifact contains the tarball and manifest.json with commit, SHA-256 and
57
+ SHA-512 integrity. Actions are pinned by commit; no dependency cache or package
58
+ lifecycle scripts are used.
59
+
60
+ After environment review, the publish job downloads that artifact without
61
+ rebuilding and repeats the main/CI/registry and artifact checks. Only one
62
+ `npm stage publish ... --tag latest --provenance --ignore-scripts` step runs with
63
+ OIDC. Network upload retries are disabled. **A successful staged workflow is
64
+ pending npm approval, not a public release.** Record the stage ID. Download its
65
+ tarball, compare it byte for byte with the retained run artifact, and obtain a
66
+ separate final approval before the owner approves that exact stage with 2FA on
67
+ npmjs.com or through interactive `npm stage approve <stage-id>`. OIDC cannot
68
+ approve, list or view stages.
69
+
70
+ After 2FA approval, check out the exact source commit and download the run
71
+ artifact into `release-dist/`. With Node 24 and npm 11.15.0:
124
72
 
125
73
  ```sh
126
74
  node scripts/release.mjs artifact
127
75
  node scripts/release.mjs verify
128
76
  ```
129
77
 
130
- Neither command publishes. verify uses public reads, an isolated no-lifecycle
131
- install and signature checks; it needs no publish token. Do not announce release
132
- completion until it passes. Stable promotion and fleet installation remain
133
- separate decisions.
78
+ Neither command publishes. Verification requires the public 0.1.0 integrity,
79
+ registry provenance metadata, `latest: 0.1.0`, unchanged
80
+ `preview: 0.1.0-preview.1`, npm signature audit, and fresh install/uninstall. Announce
81
+ stable availability only after this passes. Review a Git tag, GitHub release or
82
+ fleet rollout separately.
83
+
84
+ ## Later previews
85
+
86
+ The manual workflow still accepts `trusted-stage` for a new immutable preview
87
+ version with package `publishConfig.tag: preview`; it stages with OIDC and never
88
+ directly publishes. Preview preflight refuses an already published version and
89
+ requires `latest` to be stable or absent. The historical preview.1 exception
90
+ required its exact legacy `latest: 0.1.0-preview.0` and cannot authorize a
91
+ future version. Every stage needs artifact review and a separate 2FA approval.
134
92
 
135
93
  ## Failure handling
136
94
 
137
- Missing/invalid token, 2FA challenge, missing environment, bad OIDC mapping,
138
- non-404 registry errors, stale main, conflicting versions, upload timeout and
139
- hash mismatch are stop conditions. Never automatically change auth mode, retry
140
- upload, unpublish, move a tag, reuse a version or promote latest to obtain green
141
- checks. Inspect registry/staged state and retained evidence first. Read-only
142
- verification of the exact artifact may be repeated; publication may not.
143
- A broken/partial public release requires a reviewed new version and approval.
95
+ Missing environment protection, bad OIDC mapping, stale main, failed CI,
96
+ conflicting version or tag state, an existing stage, upload timeout, hash
97
+ mismatch and non-404 registry errors are stop conditions. Do not automatically
98
+ retry an uncertain upload, change authentication mode, move a tag, unpublish,
99
+ reuse a version or approve a stage. Inspect the run, stage and public registry
100
+ first. Registry propagation may lag a successful approval: `verify` performs
101
+ bounded read-only waits, never a second upload. A broken public release needs a
102
+ reviewed new version and its own approval.
103
+
104
+ The first preview.0 used a bootstrap token, and the registry assigned both
105
+ `preview` and `latest` to it despite `--tag preview`. That bootstrap path is
106
+ closed in this workflow. The repository `NPM_TOKEN` secret and any remaining
107
+ bootstrap credential should be removed through a separate owner-approved account
108
+ operation; this PR neither reads nor changes those credentials.
144
109
 
145
110
  ## Sources
146
111
 
147
112
  - [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/)
148
113
  - [npm staged publishing](https://docs.npmjs.com/staged-publishing)
149
- - [npm stage CLI and token/2FA behavior](https://docs.npmjs.com/cli/v11/commands/npm-stage)
114
+ - [npm stage CLI](https://docs.npmjs.com/cli/v11/commands/npm-stage)
150
115
 
151
- Requirements checked 2026-09-27: OIDC needs npm ≥11.5.1, staging ≥11.15.0, both
152
- with Node ≥22.14.0. Application support remains Node 22.13+ within 22.x and 24.x;
153
- release-tool requirements are separate.
116
+ OIDC needs npm >=11.5.1 and staging needs npm >=11.15.0 with Node >=22.14.0.
117
+ Application support remains Node 22.13+ within 22.x and 24.x; release-tool
118
+ requirements are separate.
package/VALIDATION.md CHANGED
@@ -1,8 +1,47 @@
1
1
  # Validation record — 2026-09-27 KST
2
2
 
3
- This is preview evidence, not an npm release or a claim of complete Python parity.
3
+ This record distinguishes historical live-agent evidence, the verified public
4
+ preview and the unpublished 0.1.0 stable candidate. It does not claim complete
5
+ Python parity.
4
6
  Python reference: v1.0.2, commit `47c23713d0a2a3c11ebde6186afd8c43489b8b65`.
5
7
 
8
+ ## Public 0.1.0-preview.1 — 2026-09-27 KST
9
+
10
+ The preview.1 candidate and exact merged main passed macOS/Linux Node 22/24,
11
+ native Windows Node 22/24 and the release gate. A separate gate change preserved
12
+ the existing `latest: 0.1.0-preview.0` while permitting preview.1 staging.
13
+ [Run 36313884100](https://github.com/abruption/session-peer-ts/actions/runs/36313884100)
14
+ staged the exact main commit `f65d3ee99cc26ff4a267aef84e31a308d034ac80`
15
+ through the stage-only OIDC Trusted Publisher. After separate environment and
16
+ npm 2FA approvals, the public registry showed `preview: 0.1.0-preview.1` and
17
+ unchanged `latest: 0.1.0-preview.0`.
18
+
19
+ The npm stage tarball matched the retained run artifact byte for byte. Its
20
+ SHA-256 was `d154beac93893b60b0695e5a6296e185beb8dbd05188ffe04befb74d29966110`.
21
+ The public SHA-512 integrity and provenance metadata matched the manifest;
22
+ `npm audit signatures`, fresh version-specific install, CLI version and empty
23
+ Claude discovery checks, and uninstall passed. This verifies package delivery,
24
+ not a new live Claude/Codex message ACK from the published build.
25
+
26
+ ## 0.1.0 stable candidate
27
+
28
+ This preparation updates the version handshake, package metadata, four README
29
+ languages, release gates and tests. Native delivery logic is unchanged. The
30
+ candidate must pass its PR and exact merged-main CI before a separate decision
31
+ to stage `0.1.0`; staging, npm 2FA approval, public verification and any GitHub
32
+ tag or fleet installation remain separate gates.
33
+
34
+ ## Historical 0.1.0-preview.1 candidate checks
35
+
36
+ After #7–#10 were merged, the versioned candidate passed the local macOS
37
+ arm64 Node 22 contract suite (23 passed, two Windows-only tests skipped),
38
+ TypeScript test type-check, package/declaration smoke test, four-README
39
+ repository checks and `npm audit` (zero vulnerabilities). The package smoke
40
+ test repeated packing with the same SHA-256 and verified isolated install and
41
+ uninstall. The new candidate was not sent to a live agent and was not published
42
+ by these checks. Windows Node 22/24 and macOS/Linux Node 22/24 must also pass
43
+ on the candidate PR and exact merged `main` commit before publication.
44
+
6
45
  ## Automated checks
7
46
 
8
47
  - 14 tests pass locally on macOS arm64 / Node 22.14.0.
@@ -17,6 +56,30 @@ Python reference: v1.0.2, commit `47c23713d0a2a3c11ebde6186afd8c43489b8b65`.
17
56
 
18
57
  ## Real TUI and SSH checks
19
58
 
59
+ ### Native Windows candidate — 2026-09-27 KST
60
+
61
+ An isolated prefix under the Windows user's Temp directory was installed from
62
+ this branch's local tarball. The existing Python CLI was not removed or
63
+ overwritten. Node 24.16.0 and npm 11.13.0 were present. Windows native
64
+ `list` found the live Claude inbox; Codex dry-run verified the saved thread,
65
+ kernel lock, single same-user `codex.exe` opener and stable start time. Mac
66
+ mini → Windows SSH preflight used an explicit Windows platform, remote `.cmd`
67
+ path and an already authenticated OpenSSH control socket; no host-key bypass
68
+ or new login was introduced.
69
+
70
+ One message per target was submitted from this candidate through Windows SSH.
71
+ The CLI returned Claude `posted` and Codex `queued`, with
72
+ `consumptionConfirmed:false`. The user separately confirmed exact response
73
+ lines in both Windows native TUIs:
74
+
75
+ - Claude: `ACK TS-WIN4-CLAUDE-20260927-A`
76
+ - Codex: `ACK TS-WIN4-CODEX-20260927-B`
77
+
78
+ These ACKs are user-observed responses, not direct terminal-pane capture by
79
+ the test runner. No retry was made. This is one-shot evidence, not a soak or
80
+ all-architecture qualification. Windows x64 was tested; Windows arm64 and
81
+ Node 22 remain untested in this live check. Python remains installed.
82
+
20
83
  Dedicated, newly created Claude/Codex test TUIs only. The user approved trusting
21
84
  the two otherwise empty test folders. Agent permissions were not changed.
22
85
  Each route passed discovery and no-delivery dry-run before exactly one send.
@@ -69,9 +132,9 @@ package/release fixtures, not claimed as new live ACK evidence.
69
132
 
70
133
  ## Not covered / not released
71
134
 
72
- Windows, native x64 agent interaction, IPv6 literals, Relay/MCP/wake, full Python
73
- CLI parity, automatic reverse-route detection, npm registry publishing and
74
- provenance are outside this historical evidence. Publication preparation now
75
- removes the private flag, but actual publishing still needs separate approval
76
- and the gates in RELEASING.md. Native dependency prebuild availability must be tested
77
- on each architecture claimed by a future release.
135
+ Windows arm64, IPv6 literals, Relay/MCP/wake, full Python CLI parity and
136
+ automatic reverse-route detection remain outside this evidence. Registry
137
+ publishing and provenance are verified for preview.1 above, but not by the
138
+ historical live-agent ACKs. Stable 0.1.0 publishing still needs the separate
139
+ approvals and gates in RELEASING.md. Native dependency prebuild availability
140
+ must be tested on each architecture claimed by a future release.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { type Done } from './process.js';
3
+ type Options = {
4
+ command: 'list' | 'send';
5
+ values: Map<string, string>;
6
+ flags: Set<string>;
7
+ };
8
+ export declare function parse(args: string[]): Options;
9
+ export declare function sshPreflightFailure(result: Done, expected: string): string | undefined;
10
+ export {};
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { hostname } from 'node:os';
3
+ import { lstatSync } from 'node:fs';
3
4
  import { claude, codex, Refusal } from './discovery.js';
4
5
  import { executable, run, UnknownOutcome } from './process.js';
5
6
  import { checkMessage, send } from './send.js';
@@ -18,7 +19,7 @@ export function parse(args) {
18
19
  throw new Refusal('invalid_option');
19
20
  flags.add(key);
20
21
  }
21
- else if (['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--reply-address'].includes(key)) {
22
+ else if (['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--remote-platform', '--ssh-control-path', '--reply-address'].includes(key)) {
22
23
  const name = key === '-m' ? '--message' : key;
23
24
  const value = split > 0 ? token.slice(split + 1) : args[++i];
24
25
  if (value === undefined || values.has(name))
@@ -60,8 +61,23 @@ export function parse(args) {
60
61
  }
61
62
  if (values.has('--host'))
62
63
  host(values.get('--host'));
64
+ if (values.has('--ssh-control-path')) {
65
+ if (!values.has('--host'))
66
+ throw new Refusal('inapplicable_option');
67
+ try {
68
+ if (!lstatSync(values.get('--ssh-control-path')).isSocket())
69
+ throw new Error('not_socket');
70
+ }
71
+ catch {
72
+ throw new Refusal('invalid_ssh_control_path');
73
+ }
74
+ }
75
+ if (values.has('--remote-platform') && (!values.has('--host') || !['posix', 'win32'].includes(values.get('--remote-platform'))))
76
+ throw new Refusal('invalid_remote_platform');
63
77
  if (values.has('--remote-bin')) {
64
- if (!values.has('--host') || !/^\/[\x20-\x7e]+$/.test(values.get('--remote-bin')))
78
+ const binary = values.get('--remote-bin');
79
+ if (!values.has('--host') || (values.get('--remote-platform') === 'win32' ?
80
+ !/^[A-Za-z]:\\[^\r\n]+$/.test(binary) : !/^\/[\x20-\x7e]+$/.test(binary)))
65
81
  throw new Refusal('invalid_remote_bin');
66
82
  }
67
83
  return { command, values, flags };
@@ -84,15 +100,46 @@ async function input() {
84
100
  }
85
101
  }
86
102
  const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
103
+ // Classify only the no-message version preflight. Never expose SSH stderr,
104
+ // which can contain user paths or agent output.
105
+ export function sshPreflightFailure(result, expected) {
106
+ if (result.interrupted)
107
+ return 'ssh_preflight_timeout';
108
+ if (!result.spawned)
109
+ return 'ssh_unavailable';
110
+ const detail = result.stderr;
111
+ if (/host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|no [^\n]*host key is known/i.test(detail))
112
+ return 'ssh_host_key_untrusted';
113
+ if (/permission denied \(|authentication failed|too many authentication failures|tailnet policy does not permit/i.test(detail))
114
+ return 'ssh_authentication_refused';
115
+ if (/could not resolve hostname|name or service not known|network is unreachable|no route to host|connection timed out|connection refused|operation timed out/i.test(detail))
116
+ return 'ssh_unreachable';
117
+ if (result.code === 127 && /not found|no such file|not recognized/i.test(detail))
118
+ return 'remote_cli_missing';
119
+ if (result.code === 0 && result.stdout.trim() !== expected)
120
+ return 'remote_version_mismatch';
121
+ if (result.code !== 0)
122
+ return 'ssh_preflight_failed';
123
+ return undefined;
124
+ }
87
125
  async function remote(options, message) {
88
126
  const { values, flags, command } = options;
89
127
  const target = host(values.get('--host'));
90
128
  const binary = values.get('--remote-bin') ?? 'session-peer';
129
+ const windows = values.get('--remote-platform') === 'win32';
130
+ const invoke = (flag) => {
131
+ if (!windows)
132
+ return `${quote(binary)} ${flag}`;
133
+ const code = `& '${binary.replace(/'/g, "''")}' ${flag}`;
134
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(code, 'utf16le').toString('base64')}`;
135
+ };
91
136
  const ssh = executable('ssh');
92
- const base = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', '--', target];
93
- const preflight = await run(ssh, [...base, `${quote(binary)} --version`], { timeout: 15000 });
94
- if (preflight.interrupted || preflight.code !== 0 || preflight.stdout.trim() !== VERSION_LINE)
95
- throw new Refusal('remote_version_unverified', 1);
137
+ const base = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10',
138
+ ...(values.has('--ssh-control-path') ? ['-S', values.get('--ssh-control-path')] : []), '--', target];
139
+ const preflight = await run(ssh, [...base, invoke('--version')], { timeout: 15000 });
140
+ const preflightError = sshPreflightFailure(preflight, VERSION_LINE);
141
+ if (preflightError)
142
+ throw new Refusal(preflightError, 1);
96
143
  const args = [command, '--json'];
97
144
  for (const [key, value] of values)
98
145
  if (['--agent', '--codex-home', '--codex-bin', '--to'].includes(key))
@@ -103,7 +150,7 @@ async function remote(options, message) {
103
150
  args.push('--dry-run');
104
151
  if (command === 'send')
105
152
  args.push('--message', message, '--no-from', '--no-reply-to');
106
- const done = await run(ssh, [...base, `${quote(binary)} --stdio-request`], {
153
+ const done = await run(ssh, [...base, invoke('--stdio-request')], {
107
154
  timeout: 90000, input: JSON.stringify({ schemaVersion: 1, args })
108
155
  });
109
156
  const uncertain = () => { if (command === 'send' && !flags.has('--dry-run') && done.spawned)
@@ -132,13 +179,13 @@ try {
132
179
  const [major, minor] = process.versions.node.split('.').map(Number);
133
180
  if (!((major === 22 && minor >= 13) || major === 24))
134
181
  throw new Refusal('unsupported_node_version');
135
- if (!['darwin', 'linux'].includes(process.platform))
182
+ if (!['darwin', 'linux', 'win32'].includes(process.platform))
136
183
  throw new Refusal('unsupported_platform');
137
184
  let args = process.argv.slice(2);
138
185
  if (args.length === 1 && args[0] === '--version')
139
186
  console.log(VERSION_LINE);
140
187
  else if (args.length === 1 && ['--help', '-h'].includes(args[0]))
141
- console.log('session-peer (TypeScript preview): list --agent claude|codex --json; send --to TARGET --message TEXT --json [--dry-run] [--host HOST] [--remote-bin ABSOLUTE_PATH]. Codex requires --codex-home. Relay/MCP/wake/Windows unsupported.');
188
+ console.log('session-peer (TypeScript preview): list --agent claude|codex --json; send --to TARGET --message TEXT --json [--dry-run] [--host HOST] [--remote-bin ABSOLUTE_PATH]. Codex requires --codex-home. Relay/MCP/wake unsupported.');
142
189
  else {
143
190
  const wire = args.length === 1 && args[0] === '--stdio-request';
144
191
  if (wire) {
@@ -156,7 +203,7 @@ try {
156
203
  }
157
204
  command = ['list', 'send'].includes(args[0] ?? '') ? args[0] : 'unknown';
158
205
  const options = parse(args);
159
- if (wire && (options.values.has('--host') || options.values.has('--remote-bin')))
206
+ if (wire && (options.values.has('--host') || options.values.has('--remote-bin') || options.values.has('--remote-platform') || options.values.has('--ssh-control-path')))
160
207
  throw new Refusal('nested_transport_forbidden');
161
208
  let message;
162
209
  if (command === 'send') {
@@ -0,0 +1,10 @@
1
+ type Row = Record<string, unknown>;
2
+ export declare class Refusal extends Error {
3
+ readonly code: string;
4
+ readonly exitCode: number;
5
+ constructor(code: string, exitCode?: number);
6
+ }
7
+ export declare function canonical(path: string): string;
8
+ export declare function claude(all: boolean): Row;
9
+ export declare function codex(path: string, all: boolean): Promise<Row>;
10
+ export {};
package/dist/discovery.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
+ import { windowsProcessStart } from './windows.js';
5
6
  export class Refusal extends Error {
6
7
  code;
7
8
  exitCode;
@@ -33,6 +34,8 @@ export function canonical(path) {
33
34
  function alive(pid) {
34
35
  if (pid <= 1)
35
36
  return false;
37
+ if (process.platform === 'win32')
38
+ return windowsProcessStart(pid) !== undefined;
36
39
  try {
37
40
  process.kill(pid, 0);
38
41
  return true;
@@ -71,20 +74,31 @@ export function claude(all) {
71
74
  if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid > 2147483647)
72
75
  continue;
73
76
  const matches = pid === Number(entry.slice(0, -5));
74
- const live = matches && alive(pid);
77
+ let live = matches && alive(pid);
78
+ if (live && process.platform === 'win32') {
79
+ const started = typeof record.startedAt === 'number' && Number.isSafeInteger(record.startedAt) && record.startedAt > 0 ? record.startedAt : NaN;
80
+ const ticks = windowsProcessStart(pid);
81
+ const actual = ticks === undefined ? NaN : Number((ticks - 116444736000000000n) / 10000n);
82
+ live = Number.isFinite(started) && Number.isFinite(actual) && actual <= started + 2000;
83
+ }
75
84
  const socket = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : '';
76
85
  let inbox = false;
77
86
  if (socket) {
78
- try {
79
- inbox = statSync(socket).isSocket();
80
- }
81
- catch { /* Inaccessible is not reachable. */ }
87
+ if (process.platform === 'win32')
88
+ inbox = /^\\\\\.\\pipe\\[^\r\n]+$/i.test(socket);
89
+ else
90
+ try {
91
+ inbox = statSync(socket).isSocket();
92
+ }
93
+ catch { /* Inaccessible is not reachable. */ }
82
94
  }
83
95
  const row = { agent: 'claude', pid, socket, alive: live, reachable: live && inbox };
84
96
  for (const key of ['name', 'status', 'cwd', 'kind', 'version', 'tmux'])
85
97
  row[key] = record[key] ?? null;
86
98
  if (!matches)
87
99
  row.staleReason = 'record_pid_mismatch';
100
+ else if (!live && process.platform === 'win32')
101
+ row.staleReason = 'process_unverified';
88
102
  if (all || row.reachable)
89
103
  sessions.push(row);
90
104
  }
@@ -0,0 +1 @@
1
+ export { VERSION, VERSION_LINE, reply, envelope } from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // Pure protocol helpers; importing this entry point never executes the CLI.
2
+ export { VERSION, VERSION_LINE, reply, envelope } from './protocol.js';
@@ -0,0 +1,16 @@
1
+ export declare class UnknownOutcome extends Error {
2
+ }
3
+ export declare function executable(name: string): string;
4
+ export type Done = {
5
+ code: number | null;
6
+ stdout: string;
7
+ stderr: string;
8
+ spawned: boolean;
9
+ interrupted: boolean;
10
+ };
11
+ export declare function run(binary: string, args: string[], options?: {
12
+ env?: NodeJS.ProcessEnv;
13
+ input?: string;
14
+ timeout?: number;
15
+ limit?: number;
16
+ }): Promise<Done>;
package/dist/process.js CHANGED
@@ -5,8 +5,10 @@ import { Refusal } from './discovery.js';
5
5
  export class UnknownOutcome extends Error {
6
6
  }
7
7
  export function executable(name) {
8
- const paths = isAbsolute(name) ? [name] : name.includes('/') ? [] :
9
- (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(p => join(p, name));
8
+ const windows = process.platform === 'win32';
9
+ const names = windows && !/\.(?:exe|cmd|bat)$/i.test(name) ? [name + '.exe', name + '.cmd', name] : [name];
10
+ const paths = isAbsolute(name) ? [name] : name.includes('/') || name.includes('\\') ? [] :
11
+ (process.env.PATH ?? '').split(delimiter).filter(Boolean).flatMap(p => names.map(n => join(p, n)));
10
12
  for (const path of paths) {
11
13
  try {
12
14
  accessSync(path, constants.X_OK);
@@ -19,7 +21,7 @@ export function executable(name) {
19
21
  }
20
22
  export function run(binary, args, options = {}) {
21
23
  return new Promise(resolve => {
22
- let stdout = '', bytes = 0, spawned = false, interrupted = false;
24
+ let stdout = '', stderr = '', bytes = 0, spawned = false, interrupted = false;
23
25
  const child = spawn(binary, args, { shell: false, env: options.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'] });
24
26
  const stop = () => { interrupted = true; child.kill('SIGKILL'); };
25
27
  const timer = setTimeout(stop, options.timeout ?? 3000);
@@ -34,10 +36,16 @@ export function run(binary, args, options = {}) {
34
36
  stdout += part;
35
37
  });
36
38
  // Drain but never report native stderr (it may contain message text/credentials).
37
- child.stderr.on('data', (part) => { bytes += part.length; if (bytes > (options.limit ?? 1024 * 1024))
38
- stop(); });
39
+ // Retain stderr only for allowlisted error classification. Never include it in CLI output.
40
+ child.stderr.on('data', (part) => {
41
+ bytes += part.length;
42
+ if (stderr.length < 4096)
43
+ stderr += part.toString('utf8').slice(0, 4096 - stderr.length);
44
+ if (bytes > (options.limit ?? 1024 * 1024))
45
+ stop();
46
+ });
39
47
  child.once('error', () => { interrupted = true; });
40
- child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, spawned, interrupted }); });
48
+ child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr, spawned, interrupted }); });
41
49
  child.stdin.end(options.input);
42
50
  });
43
51
  }