workoffdesk 0.0.0-stage → 1.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +198 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-DinzDcF1.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-w7i8PvU7.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,136 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ // What Claude Code's transcript says of the turn it is on, read from its end: whether the turn is over, and
3
+ // what Claude still holds in its own queue. A message typed while it works waits there (enqueue) until it is
4
+ // taken into the turn under way (remove), starts a turn of its own (dequeue) or is pulled back into the
5
+ // prompt to be changed (popAll).
6
+ // The end of the transcript read for it: what was typed long before was taken long ago.
7
+ const TAIL_BYTES = 256 * 1024;
8
+ const INTERRUPTED = '[Request interrupted';
9
+ const COMMAND = /<command-name>/;
10
+ const COMMAND_OUTPUT = /^<local-command-std(out|err)>/;
11
+ // The entries of the lines, but for commands Claude Code ran on its own (/model, /effort): each is written as
12
+ // the command and its output, and is no part of any turn. A command whose prompt goes to Claude (/review)
13
+ // has no output of its own and stays, as the prompt of its turn.
14
+ function entries(lines, wanted) {
15
+ const out = [];
16
+ for (const line of lines) {
17
+ if (!wanted(line))
18
+ continue;
19
+ try {
20
+ const e = JSON.parse(line);
21
+ if (!e || typeof e !== 'object')
22
+ continue;
23
+ if (userSays(e, COMMAND_OUTPUT)) {
24
+ const command = out.findLastIndex((x) => userSays(x, COMMAND));
25
+ if (command >= 0)
26
+ out.splice(command, 1);
27
+ continue;
28
+ }
29
+ out.push(e);
30
+ }
31
+ catch {
32
+ // a line cut off by the end of what was read, or one being written
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ const texts = (content) => typeof content === 'string'
38
+ ? [content]
39
+ : Array.isArray(content)
40
+ ? content.flatMap((b) => (b?.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
41
+ : [];
42
+ const userSays = (e, text) => e.type === 'user' && texts(e.message?.content).some((t) => text.test(t));
43
+ // The messages Claude still holds, oldest first, by the queue operations since the last turn began: what
44
+ // was held before then was taken by then.
45
+ export function heldByClaude(lines) {
46
+ const held = [];
47
+ for (const e of entries(lines, (l) => l.includes('"queue-operation"') || l.includes('"user"'))) {
48
+ if (startsTurn(e))
49
+ held.length = 0;
50
+ if (e.type !== 'queue-operation')
51
+ continue;
52
+ if (e.operation === 'enqueue')
53
+ held.push(typeof e.content === 'string' ? e.content : '');
54
+ else if (e.operation === 'dequeue')
55
+ held.shift();
56
+ else if (e.operation === 'popAll')
57
+ held.length = 0;
58
+ else if (e.operation === 'remove') {
59
+ const i = held.indexOf(e.content);
60
+ held.splice(i < 0 ? 0 : i, 1);
61
+ }
62
+ }
63
+ return held;
64
+ }
65
+ // A prompt: what a turn begins with, as against the answer to a call, a note or Esc.
66
+ function startsTurn(e) {
67
+ if (e.type !== 'user' || e.isMeta === true)
68
+ return false;
69
+ const c = e.message?.content;
70
+ if (Array.isArray(c) && c.every((b) => b?.type === 'tool_result'))
71
+ return false;
72
+ return !texts(c).some((t) => t.startsWith(INTERRUPTED));
73
+ }
74
+ // What an entry says of the turn: that it ended there, that the turn goes on, or nothing.
75
+ function said(e) {
76
+ if (e.type === 'system')
77
+ return e.subtype === 'stop_hook_summary' ? 'over' : null;
78
+ if (e.type === 'queue-operation')
79
+ return e.operation === 'dequeue' ? 'going' : null;
80
+ if (e.type === 'assistant')
81
+ return e.isApiErrorMessage === true ? 'over' : 'going';
82
+ if (e.type === 'user') {
83
+ if (e.isMeta === true)
84
+ return null;
85
+ return texts(e.message?.content).some((t) => t.startsWith(INTERRUPTED)) ? 'over' : 'going';
86
+ }
87
+ return null;
88
+ }
89
+ // Whether the turn the lines end in is over, and when it ended; null when they hold no turn.
90
+ export function lastTurn(lines) {
91
+ const all = entries(lines, () => true);
92
+ for (let i = all.length - 1; i >= 0; i--) {
93
+ const s = said(all[i]);
94
+ if (s === 'going')
95
+ return { over: false };
96
+ if (s === 'over') {
97
+ const at = typeof all[i].timestamp === 'string' ? Date.parse(all[i].timestamp) : NaN;
98
+ return { over: true, at: Number.isFinite(at) ? at : 0 };
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+ // The end of a transcript on disk, as lines; null when it cannot be read.
104
+ export function transcriptTail(path) {
105
+ let fd;
106
+ try {
107
+ fd = openSync(path, 'r');
108
+ const size = fstatSync(fd).size;
109
+ const from = Math.max(0, size - TAIL_BYTES);
110
+ const buf = Buffer.alloc(size - from);
111
+ readSync(fd, buf, 0, buf.length, from);
112
+ const lines = buf.toString('utf8').split('\n');
113
+ return from > 0 ? lines.slice(1) : lines;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ finally {
119
+ if (fd !== undefined)
120
+ closeSync(fd);
121
+ }
122
+ }
123
+ // heldByClaude for a transcript on disk; none when it cannot be read.
124
+ export function heldInTranscript(path) {
125
+ const lines = transcriptTail(path);
126
+ return lines ? heldByClaude(lines) : [];
127
+ }
128
+ // lastTurn for a transcript on disk, where a turn that ended with a message still in Claude's own queue goes
129
+ // on with it; null when it cannot be read.
130
+ export function turnInTranscript(path) {
131
+ const lines = transcriptTail(path);
132
+ if (!lines)
133
+ return null;
134
+ const turn = lastTurn(lines);
135
+ return turn?.over && heldByClaude(lines).length ? { over: false } : turn;
136
+ }
@@ -0,0 +1,60 @@
1
+ import { watch } from 'node:fs';
2
+ import { findTranscript } from './transcriptFile.js';
3
+ // A transcript that is not there yet is looked for this often; a new session writes it with its first message.
4
+ const FIND_MS = 1000;
5
+ // Changes this close together are told once: a reply is often several writes in a row.
6
+ const SETTLE_MS = 100;
7
+ // Calls `changed` whenever the session's transcript is written to, from when it first appears; the function
8
+ // returned stops it. A transcript replaced by a new file (renamed over) is found again, and so is the new
9
+ // one when the session moves to another conversation (`sessionId` is asked again each time).
10
+ export function watchTranscript(root, sessionId, changed) {
11
+ let stopped = false;
12
+ let watcher = null;
13
+ let watching;
14
+ let timer;
15
+ let settle;
16
+ const moved = setInterval(() => watcher && sessionId() !== watching && again(), FIND_MS);
17
+ const tell = () => {
18
+ clearTimeout(settle);
19
+ settle = setTimeout(() => !stopped && changed(), SETTLE_MS);
20
+ };
21
+ const find = async () => {
22
+ const sid = sessionId();
23
+ const path = sid ? await findTranscript(root, sid).catch(() => null) : null;
24
+ if (stopped)
25
+ return;
26
+ if (!path) {
27
+ timer = setTimeout(find, FIND_MS);
28
+ return;
29
+ }
30
+ try {
31
+ watcher = watch(path, (event) => {
32
+ tell();
33
+ if (event === 'rename')
34
+ again();
35
+ });
36
+ watcher.on('error', again);
37
+ watching = sid;
38
+ }
39
+ catch {
40
+ timer = setTimeout(find, FIND_MS);
41
+ return;
42
+ }
43
+ tell();
44
+ };
45
+ const again = () => {
46
+ watcher?.close();
47
+ watcher = null;
48
+ clearTimeout(timer);
49
+ if (!stopped)
50
+ timer = setTimeout(find, FIND_MS);
51
+ };
52
+ void find();
53
+ return () => {
54
+ stopped = true;
55
+ clearInterval(moved);
56
+ clearTimeout(timer);
57
+ clearTimeout(settle);
58
+ watcher?.close();
59
+ };
60
+ }
@@ -0,0 +1,36 @@
1
+ const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
2
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined);
3
+ const pct = (v) => {
4
+ const n = num(v);
5
+ return n === undefined ? undefined : Math.min(100, n);
6
+ };
7
+ const str = (v) => (typeof v === 'string' && v ? v.slice(0, 80) : undefined);
8
+ function limit(v) {
9
+ const o = obj(v);
10
+ const used = pct(o?.used_percentage);
11
+ if (used === undefined)
12
+ return undefined;
13
+ // Unix seconds.
14
+ const resetsAt = num(o?.resets_at);
15
+ return resetsAt === undefined ? { used } : { used, resetsAt };
16
+ }
17
+ export function usageFrom(payload) {
18
+ const p = obj(payload);
19
+ if (!p)
20
+ return null;
21
+ const model = obj(p.model);
22
+ const cost = obj(p.cost);
23
+ const limits = obj(p.rate_limits);
24
+ const u = {
25
+ model: str(model?.display_name) ?? str(model?.id) ?? str(p.model),
26
+ effort: str(obj(p.effort)?.level),
27
+ context: pct(obj(p.context_window)?.used_percentage),
28
+ fiveHour: limit(limits?.five_hour),
29
+ sevenDay: limit(limits?.seven_day),
30
+ cost: num(cost?.total_cost_usd),
31
+ added: num(cost?.total_lines_added),
32
+ removed: num(cost?.total_lines_removed),
33
+ };
34
+ const kept = Object.fromEntries(Object.entries(u).filter(([, v]) => v !== undefined));
35
+ return Object.keys(kept).length ? kept : null;
36
+ }
@@ -0,0 +1,263 @@
1
+ import { connectAppServer } from './appServer.js';
2
+ import { decisionOf, entriesOf, inputOf } from './items.js';
3
+ import { CodexNotifications } from './notifications.js';
4
+ import { RpcError } from './rpc.js';
5
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
6
+ const str = (v) => (typeof v === 'string' ? v : '');
7
+ const ITEMS_PAGE = 200;
8
+ const LIST_MS = 30_000;
9
+ const RETRY_MIN_MS = 1000;
10
+ const RETRY_MAX_MS = 30_000;
11
+ // A thread not written to disk yet (no turn so far) cannot be joined; its first turn writes it.
12
+ const notWrittenYet = (e) => e instanceof RpcError && /no rollout/i.test(e.message);
13
+ // WorkOffDesk's sessions of Codex, as one client of the app server their TUIs run on (docs/design/
14
+ // 2026-10-07-codex-sessions.md): it learns each session's thread, joins it, and turns what the thread does
15
+ // into the session's status, chat, asks and usage; what the phone sends goes to the thread.
16
+ export class CodexAgent {
17
+ options;
18
+ host;
19
+ rpc = null;
20
+ connecting = null;
21
+ retryMs = RETRY_MIN_MS;
22
+ retryAt = 0;
23
+ closed = false;
24
+ threads = new Map();
25
+ bySession = new Map();
26
+ // What the app server tells of the threads followed, and asks of them.
27
+ notes;
28
+ constructor(options, host) {
29
+ this.options = options;
30
+ this.host = host;
31
+ this.notes = new CodexNotifications(host, this.threads, {
32
+ track: (session, threadId) => this.track(session, threadId),
33
+ join: (t) => this.join(t),
34
+ following: (session) => this.bySession.has(session),
35
+ });
36
+ }
37
+ // Before a new session's TUI starts: the app server is running, and the thread it resumes is the
38
+ // session's. One it starts is found when it does (started).
39
+ async start(session, resume) {
40
+ await this.link();
41
+ if (!resume)
42
+ return;
43
+ this.host.setThread(session, resume);
44
+ this.track(session, resume);
45
+ }
46
+ // A session whose thread is known is followed; called on every listing, so a restart picks it up again.
47
+ follow(session, threadId) {
48
+ if (threadId && !this.bySession.has(session))
49
+ this.track(session, threadId);
50
+ if (!this.rpc?.open && Date.now() >= this.retryAt)
51
+ void this.link().catch(() => { });
52
+ }
53
+ forget(session) {
54
+ const threadId = this.bySession.get(session);
55
+ this.bySession.delete(session);
56
+ if (!threadId)
57
+ return;
58
+ const t = this.threads.get(threadId);
59
+ if (t?.liveTimer)
60
+ clearTimeout(t.liveTimer);
61
+ this.threads.delete(threadId);
62
+ void this.rpc?.request('thread/unsubscribe', { threadId }).catch(() => { });
63
+ }
64
+ // The threads people had on this Mac, newest first, as the app server lists them: it reads every session file
65
+ // to answer, which takes seconds.
66
+ async listThreads(params) {
67
+ const { data } = await (await this.link()).request('thread/list', params, LIST_MS);
68
+ return Array.isArray(data) ? data : [];
69
+ }
70
+ // One thread's metadata, read without loading it; undefined when there is no such thread.
71
+ async readThread(threadId) {
72
+ const r = await (await this.link()).request('thread/read', { threadId }).catch((e) => {
73
+ if (e instanceof RpcError && e.code < 0)
74
+ throw e;
75
+ return null;
76
+ });
77
+ return obj(r?.thread);
78
+ }
79
+ // A thread's conversation so far, as the chat shows it, read without loading or joining it.
80
+ async readItems(threadId) {
81
+ const rpc = await this.link();
82
+ const entries = [];
83
+ let cursor = null;
84
+ do {
85
+ const page = await rpc.request('thread/items/list', { threadId, limit: ITEMS_PAGE, ...(cursor && { cursor }) });
86
+ for (const d of Array.isArray(page.data) ? page.data : []) {
87
+ const at = obj(d)?.startedAtMs;
88
+ entries.push(...entriesOf(obj(d)?.item, typeof at === 'number' ? new Date(at).toISOString() : undefined));
89
+ }
90
+ cursor = typeof page.nextCursor === 'string' ? page.nextCursor : null;
91
+ } while (cursor);
92
+ return entries;
93
+ }
94
+ lastReply(session) {
95
+ return this.threadOf(session)?.said || null;
96
+ }
97
+ asks(session) {
98
+ return [...(this.threadOf(session)?.asks.values() ?? [])].map((a) => a.ask);
99
+ }
100
+ answer(session, askId, answer) {
101
+ const t = this.threadOf(session);
102
+ const held = t?.asks.get(askId);
103
+ if (!t || !held || !this.rpc?.respond(held.requestId, decisionOf(held.method, held.params, answer)))
104
+ return false;
105
+ t.asks.delete(askId);
106
+ this.host.asksChanged(session);
107
+ return true;
108
+ }
109
+ // A message for the thread, under the id it was sent with, which comes back on the item it makes. A turn
110
+ // started while one runs goes into that one.
111
+ async send(session, text, qid) {
112
+ const t = this.threadOf(session);
113
+ if (!t)
114
+ throw new Error('this Codex session has no thread yet');
115
+ const rpc = await this.link();
116
+ try {
117
+ await rpc.request('turn/start', { threadId: t.threadId, clientUserMessageId: qid, input: inputOf(text) });
118
+ }
119
+ catch (e) {
120
+ // Asked and not answered, it may have gone in: it is not sent again.
121
+ if (e instanceof RpcError && e.code === RpcError.TIMEOUT)
122
+ throw Object.assign(e, { mayHaveRun: true });
123
+ throw e;
124
+ }
125
+ }
126
+ close() {
127
+ this.closed = true;
128
+ this.rpc?.close();
129
+ }
130
+ threadOf(session) {
131
+ const id = this.bySession.get(session);
132
+ return id ? this.threads.get(id) : undefined;
133
+ }
134
+ track(session, threadId) {
135
+ const known = this.threads.get(threadId);
136
+ if (known)
137
+ return known;
138
+ const t = {
139
+ session,
140
+ threadId,
141
+ joined: false,
142
+ joining: null,
143
+ // Until the app server says otherwise: a thread runs no turn when it starts. What the session showed
144
+ // before (a wait read off its TUI's screen) was not the thread's.
145
+ status: 'idle',
146
+ compacting: false,
147
+ asks: new Map(),
148
+ files: new Map(),
149
+ live: new Map(),
150
+ liveTimer: null,
151
+ said: '',
152
+ };
153
+ this.threads.set(threadId, t);
154
+ this.bySession.set(session, threadId);
155
+ void this.join(t);
156
+ return t;
157
+ }
158
+ link() {
159
+ if (this.rpc?.open)
160
+ return Promise.resolve(this.rpc);
161
+ this.connecting ??= connectAppServer(this.options)
162
+ .then((rpc) => {
163
+ this.rpc = rpc;
164
+ this.retryMs = RETRY_MIN_MS;
165
+ rpc.onNotification((n) => this.notes.notified(n));
166
+ rpc.onRequest((r) => this.notes.requested(r));
167
+ rpc.onClose(() => this.lost(rpc));
168
+ for (const t of this.threads.values()) {
169
+ t.joined = false;
170
+ void this.join(t);
171
+ }
172
+ void this.findStarted(rpc).catch(() => { });
173
+ return rpc;
174
+ })
175
+ .catch((e) => {
176
+ this.retryAt = Date.now() + this.retryMs;
177
+ this.retryMs = Math.min(RETRY_MAX_MS, this.retryMs * 2);
178
+ this.host.log('codex app server unreachable', { error: e instanceof Error ? e.message : String(e) });
179
+ throw e;
180
+ })
181
+ .finally(() => {
182
+ this.connecting = null;
183
+ });
184
+ return this.connecting;
185
+ }
186
+ // Threads started while this server was not listening (restarted while a TUI asked trust this folder?),
187
+ // for the sessions still without one.
188
+ async findStarted(rpc) {
189
+ if (!this.host.sessions().some((s) => !s.threadId))
190
+ return;
191
+ const { data } = await rpc.request('thread/loaded/list', {});
192
+ for (const threadId of Array.isArray(data) ? data.map(str) : []) {
193
+ if (!threadId || this.threads.has(threadId))
194
+ continue;
195
+ const { thread } = await rpc.request('thread/read', { threadId });
196
+ this.notes.started(obj(thread));
197
+ }
198
+ }
199
+ // What the app server held for the phone went with it; the TUIs that were on it are gone too. The next
200
+ // listing connects again, starting it if need be.
201
+ lost(rpc) {
202
+ if (this.rpc !== rpc)
203
+ return;
204
+ this.rpc = null;
205
+ for (const t of this.threads.values()) {
206
+ t.joined = false;
207
+ if (t.asks.size) {
208
+ t.asks.clear();
209
+ this.host.asksChanged(t.session);
210
+ }
211
+ }
212
+ if (!this.closed)
213
+ this.host.log('codex app server connection lost', {});
214
+ }
215
+ // Joins the thread and takes what it holds so far into the journal. One not written yet is joined after
216
+ // its first turn starts.
217
+ join(t) {
218
+ if (t.joined || !this.rpc?.open)
219
+ return Promise.resolve();
220
+ t.joining ??= (async () => {
221
+ const rpc = this.rpc;
222
+ try {
223
+ const r = await rpc.request('thread/resume', { threadId: t.threadId, excludeTurns: true });
224
+ t.joined = true;
225
+ t.model = str(r.model) || t.model;
226
+ t.effort = str(r.reasoningEffort) || t.effort;
227
+ this.notes.statusIs(t, obj(obj(r.thread)?.status));
228
+ this.notes.nameIs(t, obj(r.thread)?.name);
229
+ this.notes.usage(t);
230
+ await this.backfill(rpc, t);
231
+ }
232
+ catch (e) {
233
+ if (notWrittenYet(e)) {
234
+ const thread = obj((await rpc.request('thread/read', { threadId: t.threadId }).catch(() => null))?.thread);
235
+ this.notes.ranOn(t, thread);
236
+ this.notes.statusIs(t, obj(thread?.status));
237
+ }
238
+ else
239
+ this.host.log('codex thread not joined', { session: t.session, thread: t.threadId, error: e instanceof Error ? e.message : String(e) });
240
+ }
241
+ finally {
242
+ t.joining = null;
243
+ }
244
+ })();
245
+ return t.joining;
246
+ }
247
+ async backfill(rpc, t) {
248
+ let cursor = null;
249
+ do {
250
+ const page = await rpc.request('thread/items/list', { threadId: t.threadId, limit: ITEMS_PAGE, ...(cursor && { cursor }) });
251
+ const entries = (Array.isArray(page.data) ? page.data : []).flatMap((d) => {
252
+ const at = obj(d)?.startedAtMs;
253
+ return entriesOf(obj(d)?.item, typeof at === 'number' ? new Date(at).toISOString() : undefined);
254
+ });
255
+ if (entries.length && this.threads.get(t.threadId) === t)
256
+ this.host.journal.putAgentItems(t.session, t.threadId, entries);
257
+ const reply = entries.findLast((e) => e.item.k === 'text')?.item;
258
+ if (reply?.k === 'text')
259
+ t.said = reply.text;
260
+ cursor = typeof page.nextCursor === 'string' ? page.nextCursor : null;
261
+ } while (cursor);
262
+ }
263
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os';
2
+ import { CodexRpc } from './rpc.js';
3
+ // Its own tmux session, so it outlives a restart of this server as the sessions' TUIs do; not named like a
4
+ // session (sessionName.ts), so no listing shows it.
5
+ export const APP_SERVER_TMUX = 'workoffdesk-codex';
6
+ const STARTS_MS = 15_000;
7
+ const LOOK_MS = 250;
8
+ const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
9
+ // The command a session's terminal runs: Codex's TUI on the shared app server, in the session's folder.
10
+ export function tuiCommand(o) {
11
+ const resume = o.resume ? ` resume ${shq(o.resume)}` : '';
12
+ const inner = `${shq(o.codexBin)} --remote ${shq(`unix://${o.sock}`)} -C ${shq(o.cwd)} -c check_for_update_on_startup=false${resume}; exec ${shq(o.shell)} -l`;
13
+ return `${shq(o.shell)} -lc ${shq(inner)}`;
14
+ }
15
+ // A connection to the app server, started first when nothing answers on its socket. A tmux session left
16
+ // from one that died is ended before another is started.
17
+ export async function connectAppServer(o) {
18
+ try {
19
+ return await CodexRpc.connect(o.sock);
20
+ }
21
+ catch {
22
+ // not running; started below
23
+ }
24
+ await o.tmux.killSession(APP_SERVER_TMUX);
25
+ const inner = `exec ${shq(o.codexBin)} app-server --listen ${shq(`unix://${o.sock}`)}`;
26
+ await o.tmux.newSession(APP_SERVER_TMUX, { cwd: homedir(), command: `${shq(o.shell)} -lc ${shq(inner)}`, env: {} });
27
+ const until = Date.now() + STARTS_MS;
28
+ for (;;) {
29
+ await new Promise((r) => setTimeout(r, LOOK_MS));
30
+ try {
31
+ return await CodexRpc.connect(o.sock);
32
+ }
33
+ catch (e) {
34
+ if (Date.now() > until)
35
+ throw new Error(`the Codex app server did not start: ${e instanceof Error ? e.message : String(e)}`);
36
+ }
37
+ }
38
+ }