@volter/supercode-teams 0.2.176 → 0.2.178

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/bin/teams.mjs CHANGED
@@ -307,12 +307,13 @@ async function main() {
307
307
  case 'workspace': {
308
308
  if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('workspace.list', {})));
309
309
  if (sub === 'register' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.register', { path: rest[0], repository: flags.repo ?? null })));
310
+ if (sub === 'unregister' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.unregister', { path: rest[0] })));
310
311
  if (sub === 'resolve' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.resolve', { repository: rest[0], branch: flags.branch ?? null, solo: Boolean(flags.solo), card: flags.card ?? null, create: Boolean(flags.create) })));
311
312
  if (sub === 'inspect' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.inspect', { path: rest[0] })));
312
313
  // `workspace move --from A --to B --path P [--to-path Q]`: the project leaves A and arrives
313
314
  // at B, published there only if every byte arrived (sha256 on commit) and neither machine
314
315
  // runs an exec job. A is left as it was; B's path must not exist.
315
- if (sub !== 'move' || !flags.from || !flags.to || !flags.path) die('workspace move --from MACHINE --to MACHINE --path P [--to-path Q]');
316
+ if (sub !== 'move' || !flags.from || !flags.to || !flags.path) die('workspace list | register PATH [--repo R] | unregister PATH | resolve REPO | inspect PATH | move --from MACHINE --to MACHINE --path P [--to-path Q]');
316
317
  try {
317
318
  out(await moveWorkspace({ from: String(flags.from), to: String(flags.to), path: String(flags.path), toPath: String(flags['to-path'] ?? flags.path), context: flags.context ? String(flags.context) : null, home }));
318
319
  } catch (error) { die(error.message, error.exitCode ?? 1); }
package/machine.mjs CHANGED
@@ -1799,11 +1799,12 @@ export class TeamsMachine extends MachineCore {
1799
1799
  act('title', params.target, 'ok', renamed);
1800
1800
  return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
1801
1801
  }
1802
- case 'workspace.inspect': case 'workspace.register': case 'workspace.list': case 'workspace.resolve': {
1802
+ case 'workspace.inspect': case 'workspace.register': case 'workspace.unregister': case 'workspace.list': case 'workspace.resolve': {
1803
1803
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1804
1804
  const registry = new Workspaces(this.home);
1805
1805
  const result = method === 'workspace.inspect' ? inspectCheckout(params.path)
1806
1806
  : method === 'workspace.register' ? registry.register(params.path, params.repository)
1807
+ : method === 'workspace.unregister' ? registry.unregister(params.path)
1807
1808
  : method === 'workspace.list' ? registry.list()
1808
1809
  : registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
1809
1810
  act('workspace', '*', 'ok', method);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.176",
3
+ "version": "0.2.178",
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' });
package/workspaces.mjs CHANGED
@@ -25,18 +25,27 @@ export function inspectCheckout(path) {
25
25
  }
26
26
  export class Workspaces {
27
27
  constructor(home) { this.file = join(home, 'workspaces.jsonl'); }
28
- list() {
28
+ // Every registration stands until its checkout is registered again or unregistered; the newest standing row
29
+ // answers each repository and branch (list). Unregistering a registration made by mistake therefore gives back
30
+ // the one it replaced; the removal row stays in the file as the record of that act.
31
+ standing() {
29
32
  let text; try { text = readFileSync(this.file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
30
- const map = new Map();
33
+ let standing = [];
31
34
  for (const line of text.split('\n').filter(Boolean)) {
32
35
  let row; try { row = JSON.parse(line); } catch { throw new Error(`invalid machine workspace map ${this.file}`); }
33
- for (const [key, old] of map) {
34
- if (old.path === row.path) map.delete(key);
35
- else if (row.primary && old.repository === row.repository) old.primary = false;
36
- }
37
- map.set(`${row.repository}\n${row.branch ?? ''}`, row);
36
+ standing = standing.filter((old) => old.path !== row.path);
37
+ if (!row.removed) standing.push(row);
38
38
  }
39
- return [...map.values()];
39
+ return standing;
40
+ }
41
+ list() {
42
+ const map = new Map();
43
+ for (const row of this.standing()) { const key = `${row.repository}\n${row.branch ?? ''}`; map.delete(key); map.set(key, { ...row }); }
44
+ // A repository's main checkout is its newest registered main checkout that stands.
45
+ const rows = [...map.values()], main = new Map();
46
+ for (const row of rows) if (row.primary) main.set(row.repository, row);
47
+ for (const row of rows) row.primary = main.get(row.repository) === row;
48
+ return rows;
40
49
  }
41
50
  register(path, repository = null) {
42
51
  const info = inspectCheckout(path), id = repositoryId(repository ?? info.repository);
@@ -46,6 +55,16 @@ export class Workspaces {
46
55
  appendFileSync(this.file, JSON.stringify(row) + '\n', { mode: 0o600 });
47
56
  return row;
48
57
  }
58
+ // A registration made by mistake is removed: the checkout at `path` (its root, as registered) leaves the map. The
59
+ // checkout itself is untouched.
60
+ unregister(path) {
61
+ let at = resolve(path);
62
+ try { at = inspectCheckout(path).root; } catch { try { at = realpathSync(at); } catch { /* a path that no longer exists */ } }
63
+ const row = this.standing().findLast(r => r.path === at || r.path === resolve(path));
64
+ if (!row) throw new Error(`${path} is not registered in this machine's workspace map`);
65
+ appendFileSync(this.file, JSON.stringify({ path: row.path, removed: true, repository: row.repository, branch: row.branch }) + '\n', { mode: 0o600 });
66
+ return { ...row, removed: true };
67
+ }
49
68
  resolve(repository, branch = null, { solo = false, card = null, create = false } = {}) {
50
69
  const id = repositoryId(repository), rows = this.list().filter(r => r.repository === id);
51
70
  if (!rows.length) throw new Error(`repository ${id} is missing from this machine's workspace map; register its checkout with teams workspace register <path>`);