@volter/supercode-teams 0.2.175 → 0.2.177

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
@@ -61,7 +61,13 @@ anything else, for a wildcard (`pane:*`) or for the whole filesystem (`root:/`)
61
61
  token must carry `iat` and live at most 15 minutes (`exp - iat` and `exp - now`). The hosted viewer
62
62
  at `/view#token=…` opens it; the server turns the token into a machine grant on exactly that target
63
63
  (a pane: `observe`, and `control` to type; a folder: `observe` to read, `files` to change) that
64
- expires with the token, and closes the channel then. A page embedding the viewer sends it a fresh
64
+ expires with the token, and closes the channel then. Before it writes that grant, and while the
65
+ channel is open every 15 seconds, the server asks the workplace whether the token still admits what it was issued for
66
+ (`POST <issuer>/api/v3/delegated/status`); when the workplace answers that it does not (the member's
67
+ binding revoked, their Task closed, their contract ended), the grant is deleted and the channel closes
68
+ with `access_revoked`, which the viewer shows rather than reconnecting; an open with such a token is
69
+ refused (403 `access_revoked`) and writes no grant. An unreachable workplace leaves the channel to
70
+ the token's expiry. A page embedding the viewer sends it a fresh
65
71
  token by `postMessage({ type: 'delegated-token', token })`, which the viewer asks for with
66
72
  `delegated-token-needed`.
67
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.175",
3
+ "version": "0.2.177",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
package/terminal-mail.mjs CHANGED
@@ -3,9 +3,20 @@
3
3
  import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
6
7
  import { StringDecoder } from 'node:string_decoder';
7
8
  import { codexUpdatePrompt } from './codex-startup.mjs';
8
9
 
10
+ // When process `pid` started (ms), from `ps -o etime=` ([[dd-]hh:]mm:ss); null when it cannot be read.
11
+ function startedAt(pid) {
12
+ if (!Number.isInteger(pid)) return null;
13
+ const out = spawnSync('ps', ['-o', 'etime=', '-p', String(pid)], { encoding: 'utf8' }).stdout?.trim();
14
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(out ?? '');
15
+ if (!m) return null;
16
+ const [, d = 0, h = 0, mi, se] = m.map((v) => (v === undefined ? 0 : Number(v)));
17
+ return Date.now() - (((d * 24 + h) * 60 + mi) * 60 + se) * 1000;
18
+ }
19
+
9
20
  const hash = value => createHash('sha256').update(value).digest('hex');
10
21
  const textOf = content => typeof content === 'string' ? content : Array.isArray(content) && content.every(p => ['text', 'input_text'].includes(p.type)) ? content.map(p => p.text).join('') : null;
11
22
 
@@ -85,6 +96,13 @@ export class TerminalMailbox {
85
96
  this.state.binding = { context: session.contextKey, terminal: session.id, pid: session.rootPid, identity }; this.save();
86
97
  return messageIds;
87
98
  }
99
+ /** Whether `batch` was pasted into another terminal than the one the session runs in now. A batch from before
100
+ * pastes recorded their terminal is judged by its last Enter: a process started after it never saw the paste. */
101
+ pastedElsewhere(batch) {
102
+ if (batch.terminal) return batch.terminal !== this.session.id || (batch.pid != null && batch.pid !== this.session.rootPid);
103
+ const started = startedAt(this.session.rootPid);
104
+ return Boolean(batch.last_enter) && started !== null && started > batch.last_enter;
105
+ }
88
106
  kick() {
89
107
  if (this.work) return this.work;
90
108
  this.work = this.drain().catch(error => {
@@ -117,6 +135,16 @@ export class TerminalMailbox {
117
135
  this.state.receipts.push(receipt);
118
136
  this.state.active = null; this.save(); this.log(receipt); return;
119
137
  }
138
+ if (batch.phase !== 'prepared' && this.session && this.pastedElsewhere(batch)) {
139
+ // The paste went to a terminal the session no longer runs in (its pane's process was replaced, a resume): that
140
+ // composer died with its process and the transcript never got the text, so the batch is pasted again here.
141
+ // Nothing is guessed: the transcript was read above, from the paste's own offset, and holds no such message.
142
+ this.log({ delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, reason: `pasted into terminal ${batch.terminal ?? 'unrecorded'}, which the session no longer runs in; pasting again into ${this.session.id}` });
143
+ const baseline = batch.kind === 'codex' ? null : await this.load(this.identity.locator, { message_tail: 1 });
144
+ Object.assign(batch, { phase: 'prepared', enters: 0, last_enter: 0, error: null, path: this.identity.path, locator: this.identity.locator,
145
+ offset: batch.kind === 'codex' ? statSync(this.identity.path).size : null, messageOffset: baseline?.window?.total_messages ?? null });
146
+ this.save();
147
+ }
120
148
  if (batch.kind === 'codex' && codexUpdatePrompt(await this.host.capture(this.session.id, { lines: 120 }))) {
121
149
  batch.error = 'codex-update-prompt: delivery retained; restart Codex with check_for_update_on_startup=false';
122
150
  this.save(); this.log({ delivered: false, queued: true, reason: batch.error, message_ids: batch.message_ids }); return;
@@ -124,7 +152,7 @@ export class TerminalMailbox {
124
152
  if (batch.phase === 'prepared') {
125
153
  // Persist uncertainty before I/O. A daemon restart checks the transcript;
126
154
  // it never guesses that an unacknowledged paste did not happen.
127
- batch.phase = 'paste_started'; this.save();
155
+ Object.assign(batch, { phase: 'paste_started', terminal: this.session.id, pid: this.session.rootPid ?? null, pasted_at: Date.now() }); this.save();
128
156
  // The terminal host's final newline selects one bracketed paste;
129
157
  // submitMode:none holds the separate Enter until the paste completes.
130
158
  await this.host.sendInput(this.session.id, `${batch.text}\n`, { submitMode: 'none' });