workoffdesk 1.0.1 → 1.0.2
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/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/server/agents/claude/screen/background.js +7 -0
- package/dist/server/agents/claude/screen/prompt.js +34 -0
- package/dist/server/agents/claude/screen/screen.js +42 -3
- package/dist/server/agents/claude/terminals.js +67 -12
- package/dist/server/agents/claude/transcript/chat.js +98 -135
- package/dist/server/agents/claude/transcript/events.js +158 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
- package/dist/server/agents/claude/transcript/transcripts.js +118 -30
- package/dist/server/agents/claude/transcript/turn.js +40 -61
- package/dist/server/agents/codex/agent.js +9 -2
- package/dist/server/agents/codex/events.js +107 -0
- package/dist/server/agents/codex/items.js +54 -74
- package/dist/server/agents/codex/notifications.js +14 -13
- package/dist/server/agents/codex/threads.js +54 -40
- package/dist/server/agents/events.js +4 -0
- package/dist/server/agents/turnSays.js +58 -0
- package/dist/server/cli.js +2 -0
- package/dist/server/commands/activity.js +28 -0
- package/dist/server/http/app.js +8 -4
- package/dist/server/http/auth/guard.js +8 -4
- package/dist/server/http/auth/passkey.js +6 -3
- package/dist/server/http/routes/appUpdate.js +1 -1
- package/dist/server/http/routes/chat.js +10 -0
- package/dist/server/http/routes/claude.js +70 -16
- package/dist/server/http/routes/codex.js +38 -3
- package/dist/server/http/routes/journal.js +3 -1
- package/dist/server/http/routes/push.js +36 -5
- package/dist/server/http/routes/pushRelay.js +62 -0
- package/dist/server/http/routes/sessions.js +32 -1
- package/dist/server/http/serve.js +8 -2
- package/dist/server/journal/db.js +4 -0
- package/dist/server/journal/index.js +4 -0
- package/dist/server/journal/ingest.js +26 -13
- package/dist/server/notify/apns.js +12 -7
- package/dist/server/notify/push.js +30 -4
- package/dist/server/notify/seal.js +12 -0
- package/dist/server/ops/doctor.js +7 -6
- package/dist/server/ops/problems.js +12 -7
- package/dist/server/sessions/control.js +65 -0
- package/dist/server/sessions/events.js +7 -5
- package/dist/server/sessions/machine/input.js +21 -2
- package/dist/server/sessions/machine/lifecycle.js +151 -81
- package/dist/server/sessions/machine/moves.js +65 -13
- package/dist/server/sessions/machine/naming.js +85 -0
- package/dist/server/sessions/machine/state.js +2 -0
- package/dist/server/sessions/machine/status.js +1 -2
- package/dist/server/sessions/machine/takeOvers.js +92 -0
- package/dist/server/sessions/machine.js +87 -23
- package/dist/server/sessions/outbox.js +7 -1
- package/dist/server/system/config.js +16 -1
- package/dist/server/system/errors.js +2 -2
- package/dist/server/system/messages.js +14 -176
- package/dist/server/system/messages.zh.js +186 -0
- package/dist/server/system/project.js +65 -0
- package/dist/server/system/store.js +1 -1
- package/dist/server/system/tmux.js +1 -1
- package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
- package/dist/web/assets/main-DFndf3Z8.js +132 -0
- package/dist/web/assets/main-mChS_ic8.css +1 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon.svg +14 -6
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +1 -1
- package/dist/web/sw.js +4 -4
- package/package.json +3 -3
- package/dist/web/assets/main-C8GiLoJy.css +0 -1
- package/dist/web/assets/main-DQFM4yJU.js +0 -132
|
@@ -19,15 +19,17 @@ export function loadApnsKey(home) {
|
|
|
19
19
|
catch {
|
|
20
20
|
throw wrong('not JSON');
|
|
21
21
|
}
|
|
22
|
-
const { teamId, keyId, keyFile } = raw ?? {};
|
|
22
|
+
const { teamId, keyId, keyFile, relayTopic } = raw ?? {};
|
|
23
23
|
if (typeof teamId !== 'string' || !teamId)
|
|
24
24
|
throw wrong('"teamId" is missing (the Team ID in the Apple Developer account)');
|
|
25
25
|
if (typeof keyId !== 'string' || !keyId)
|
|
26
26
|
throw wrong('"keyId" is missing (the 10 characters in AuthKey_<keyId>.p8)');
|
|
27
27
|
if (typeof keyFile !== 'string' || !existsSync(keyFile))
|
|
28
28
|
throw wrong(`"keyFile" names no file: ${String(keyFile)}`);
|
|
29
|
+
if (relayTopic !== undefined && (typeof relayTopic !== 'string' || !/^[A-Za-z0-9.-]{1,155}$/.test(relayTopic)))
|
|
30
|
+
throw wrong(`"relayTopic" is not a bundle id: ${String(relayTopic)}`);
|
|
29
31
|
try {
|
|
30
|
-
return { teamId, keyId, key: createPrivateKey(readFileSync(keyFile)) };
|
|
32
|
+
return { teamId, keyId, key: createPrivateKey(readFileSync(keyFile)), ...(relayTopic ? { relayTopic } : {}) };
|
|
31
33
|
}
|
|
32
34
|
catch {
|
|
33
35
|
throw wrong(`"keyFile" is not a .p8 key: ${keyFile}`);
|
|
@@ -43,6 +45,9 @@ export class Apns {
|
|
|
43
45
|
this.post = post;
|
|
44
46
|
this.now = now;
|
|
45
47
|
}
|
|
48
|
+
get relayTopic() {
|
|
49
|
+
return this.key.relayTopic;
|
|
50
|
+
}
|
|
46
51
|
// A device that has not answered yet is tried on the production service (TestFlight and the App Store),
|
|
47
52
|
// then on the sandbox (builds from Xcode). host: the service it answered on.
|
|
48
53
|
async send(device, payload, collapseId) {
|
|
@@ -62,18 +67,18 @@ export class Apns {
|
|
|
62
67
|
res = await this.post(host, `/3/device/${device.token}`, headers, body);
|
|
63
68
|
}
|
|
64
69
|
catch {
|
|
65
|
-
return { host: device.host, gone: false };
|
|
70
|
+
return { host: device.host, gone: false, sent: false };
|
|
66
71
|
}
|
|
67
72
|
if (res.status === 200)
|
|
68
|
-
return { host, gone: false };
|
|
73
|
+
return { host, gone: false, sent: true };
|
|
69
74
|
if (res.status === 410)
|
|
70
|
-
return { host, gone: true };
|
|
75
|
+
return { host, gone: true, sent: false };
|
|
71
76
|
if (res.status === 403 && /ProviderToken/.test(res.reason ?? ''))
|
|
72
77
|
this.token = null;
|
|
73
78
|
if (res.reason !== 'BadDeviceToken')
|
|
74
|
-
return { host: device.host ?? host, gone: false };
|
|
79
|
+
return { host: device.host ?? host, gone: false, sent: false };
|
|
75
80
|
}
|
|
76
|
-
return { gone: true };
|
|
81
|
+
return { gone: true, sent: false };
|
|
77
82
|
}
|
|
78
83
|
bearer() {
|
|
79
84
|
const t = this.now();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import webpush from 'web-push';
|
|
2
|
+
import { seal, threadOf } from './seal.js';
|
|
2
3
|
const COOLDOWN = 30_000;
|
|
3
4
|
// The most of what Claude said a notification carries; the phone shows the first lines of it.
|
|
4
5
|
const SAID_MAX = 300;
|
|
@@ -15,12 +16,15 @@ export function plainLine(said) {
|
|
|
15
16
|
const chars = Array.from(text);
|
|
16
17
|
return chars.length > SAID_MAX ? `${chars.slice(0, SAID_MAX - 1).join('')}…` : text;
|
|
17
18
|
}
|
|
19
|
+
const RELAY_WAIT_MS = 10_000;
|
|
20
|
+
const relayFetch = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(RELAY_WAIT_MS) }).then((r) => r.status);
|
|
18
21
|
export class Pusher {
|
|
19
22
|
store;
|
|
20
23
|
send;
|
|
21
24
|
now;
|
|
22
25
|
subject;
|
|
23
26
|
apns;
|
|
27
|
+
relayPost;
|
|
24
28
|
lastSent = new Map();
|
|
25
29
|
visible = new Map();
|
|
26
30
|
constructor(store, opts) {
|
|
@@ -29,6 +33,7 @@ export class Pusher {
|
|
|
29
33
|
this.now = opts.now ?? Date.now;
|
|
30
34
|
this.subject = opts.subject;
|
|
31
35
|
this.apns = opts.apns;
|
|
36
|
+
this.relayPost = opts.relayPost ?? relayFetch;
|
|
32
37
|
}
|
|
33
38
|
// Whether the iPhone app can be sent notifications from here.
|
|
34
39
|
get toPhones() {
|
|
@@ -48,7 +53,7 @@ export class Pusher {
|
|
|
48
53
|
async notify(sessionId, title, event, said) {
|
|
49
54
|
const { vapid, pushSubs, apnsDevices } = this.store.get();
|
|
50
55
|
const browsers = vapid ? pushSubs : [];
|
|
51
|
-
const phones = this.
|
|
56
|
+
const phones = apnsDevices.filter((d) => (this.relayed(d) ? true : !!this.apns));
|
|
52
57
|
if ((!browsers.length && !phones.length) || this.visible.has(sessionId))
|
|
53
58
|
return;
|
|
54
59
|
const t = this.now();
|
|
@@ -85,11 +90,21 @@ export class Pusher {
|
|
|
85
90
|
});
|
|
86
91
|
}
|
|
87
92
|
}
|
|
93
|
+
// An App Store app's device goes through its relay, unless this server is that relay; any other, to Apple
|
|
94
|
+
// with this server's key.
|
|
95
|
+
relayed(d) {
|
|
96
|
+
return !!d.relay && this.apns?.relayTopic !== d.topic;
|
|
97
|
+
}
|
|
88
98
|
async toApp(devices, sessionId, title, event, said) {
|
|
89
|
-
|
|
90
|
-
if (!devices.length || !apns)
|
|
99
|
+
if (!devices.length)
|
|
91
100
|
return;
|
|
92
|
-
const results = await Promise.all(devices.map(
|
|
101
|
+
const results = await Promise.all(devices.map(async (d) => {
|
|
102
|
+
const alert = alertOf(title, d.words[event], event, said);
|
|
103
|
+
if (this.relayed(d))
|
|
104
|
+
return { token: d.token, gone: await this.throughRelay(d, sessionId, alert) };
|
|
105
|
+
const { host, gone } = await this.apns.send(d, { aps: { alert, sound: 'default', 'thread-id': sessionId }, body: { sessionId } }, sessionId);
|
|
106
|
+
return { token: d.token, host, gone };
|
|
107
|
+
}));
|
|
93
108
|
const gone = new Set(results.filter((r) => r.gone).map((r) => r.token));
|
|
94
109
|
const learned = new Map(results.filter((r) => !r.gone && r.host).map((r) => [r.token, r.host]));
|
|
95
110
|
const known = new Map(devices.map((d) => [d.token, d.host]));
|
|
@@ -99,6 +114,17 @@ export class Pusher {
|
|
|
99
114
|
s.apnsDevices = s.apnsDevices.filter((d) => !gone.has(d.token)).map((d) => (learned.has(d.token) ? { ...d, host: learned.get(d.token) } : d));
|
|
100
115
|
});
|
|
101
116
|
}
|
|
117
|
+
// Whether the relay says the device is gone.
|
|
118
|
+
async throughRelay(d, sessionId, alert) {
|
|
119
|
+
const { url, key } = d.relay;
|
|
120
|
+
const body = { token: d.token, topic: d.topic, sealed: seal(key, { alert, sessionId }), thread: threadOf(key, sessionId), hidden: d.words.hidden ?? '' };
|
|
121
|
+
try {
|
|
122
|
+
return (await this.relayPost(`${url}/api/push/relay`, JSON.stringify(body))) === 410;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
102
128
|
}
|
|
103
129
|
// The phone's words for the event say what happened, and what Claude said, what about. A finished turn's
|
|
104
130
|
// reply says by itself that the turn is over.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createCipheriv, createHmac, randomBytes } from 'node:crypto';
|
|
2
|
+
// A notification for the App Store app goes through a relay that must not read it. It is sealed with the key
|
|
3
|
+
// the phone gave this server: AES-256-GCM, as nonce, ciphertext and tag one after another, which is how
|
|
4
|
+
// CryptoKit's AES.GCM.SealedBox(combined:) in the phone's notification extension reads it.
|
|
5
|
+
export function seal(keyB64, said) {
|
|
6
|
+
const nonce = randomBytes(12);
|
|
7
|
+
const c = createCipheriv('aes-256-gcm', Buffer.from(keyB64, 'base64'), nonce);
|
|
8
|
+
const text = Buffer.concat([c.update(JSON.stringify(said), 'utf8'), c.final()]);
|
|
9
|
+
return Buffer.concat([nonce, text, c.getAuthTag()]).toString('base64');
|
|
10
|
+
}
|
|
11
|
+
// The thread a session's notifications are grouped under, which the relay sees.
|
|
12
|
+
export const threadOf = (keyB64, sessionId) => createHmac('sha256', Buffer.from(keyB64, 'base64')).update(sessionId).digest('hex').slice(0, 16);
|
|
@@ -61,12 +61,13 @@ async function serviceChecks(d, doc, config) {
|
|
|
61
61
|
? fail('service', doc.otherVersion(local, service.version, d.version), doc.restartFix(config.serviceLabel))
|
|
62
62
|
: pass('service', doc.running(local, service.version)),
|
|
63
63
|
];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
for (const origin of [config.origin, ...config.extraOrigins]) {
|
|
65
|
+
if (LOCAL.test(origin)) {
|
|
66
|
+
checks.push(pass('origin', doc.localOnly(origin)));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const there = await d.fetch(`${origin}/healthz`);
|
|
70
|
+
checks.push(there.ok ? pass('origin', origin) : fail('origin', doc.notReached(origin), doc.originFix(origin, local)));
|
|
70
71
|
}
|
|
71
72
|
return checks;
|
|
72
73
|
}
|
|
@@ -38,17 +38,22 @@ function parse(text) {
|
|
|
38
38
|
return null;
|
|
39
39
|
return { at: new Date(time).toISOString(), level: named, msg: typeof msg === 'string' ? msg : '', detail: JSON.stringify(rest).slice(0, DETAIL_MAX) };
|
|
40
40
|
}
|
|
41
|
+
// The newest first, the same words at the same level said once: limit is of different problems.
|
|
41
42
|
export function recentProblems(logFile, limit = 50) {
|
|
42
|
-
const found =
|
|
43
|
+
const found = new Map();
|
|
43
44
|
for (const file of [logFile, `${logFile}.1`]) {
|
|
44
45
|
const lines = tail(file).split('\n');
|
|
45
|
-
for (let i = lines.length - 1; i >= 0
|
|
46
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
46
47
|
const p = lines[i] && parse(lines[i]);
|
|
47
|
-
if (p)
|
|
48
|
-
|
|
48
|
+
if (!p)
|
|
49
|
+
continue;
|
|
50
|
+
const key = `${p.level} ${p.msg}`;
|
|
51
|
+
const seen = found.get(key);
|
|
52
|
+
if (seen)
|
|
53
|
+
seen.count++;
|
|
54
|
+
else if (found.size < limit)
|
|
55
|
+
found.set(key, { ...p, count: 1 });
|
|
49
56
|
}
|
|
50
|
-
if (found.length >= limit)
|
|
51
|
-
break;
|
|
52
57
|
}
|
|
53
|
-
return found;
|
|
58
|
+
return [...found.values()];
|
|
54
59
|
}
|
|
@@ -15,6 +15,25 @@ function openSession(open, body) {
|
|
|
15
15
|
return bad('kind must be claude or codex');
|
|
16
16
|
return open({ kind: r.kind, cwd: r.cwd, resumeId: r.resumeId, env: r.env }).then((id) => ({ status: 201, body: { id } }), (e) => ({ status: 400, body: { error: e instanceof Error ? e.message : String(e) } }));
|
|
17
17
|
}
|
|
18
|
+
// The working count as a line of JSON at once, then again each time it changes, until the client goes away.
|
|
19
|
+
function streamActivity(activity, res) {
|
|
20
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
|
|
21
|
+
let last = -1;
|
|
22
|
+
const check = () => {
|
|
23
|
+
const working = activity.working();
|
|
24
|
+
if (working === last)
|
|
25
|
+
return;
|
|
26
|
+
last = working;
|
|
27
|
+
res.write(`${JSON.stringify({ working })}\n`);
|
|
28
|
+
};
|
|
29
|
+
check();
|
|
30
|
+
const unwatch = activity.onChange(check);
|
|
31
|
+
const timer = setInterval(check, activity.recheckMs ?? 2000).unref();
|
|
32
|
+
res.on('close', () => {
|
|
33
|
+
unwatch();
|
|
34
|
+
clearInterval(timer);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
18
37
|
export function startControlServer(sockPath, o) {
|
|
19
38
|
const answered = (view) => view.then((body) => ({ status: 200, body }), (e) => {
|
|
20
39
|
const r = errorReply(e);
|
|
@@ -46,6 +65,8 @@ export function startControlServer(sockPath, o) {
|
|
|
46
65
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
47
66
|
res.end(JSON.stringify(body));
|
|
48
67
|
};
|
|
68
|
+
if (req.method === 'GET' && req.url === '/activity')
|
|
69
|
+
return streamActivity(o.activity, res);
|
|
49
70
|
const route = routes[`${req.method} ${req.url}`];
|
|
50
71
|
if (!route)
|
|
51
72
|
return reply({ status: 404, body: { error: 'not found' } });
|
|
@@ -95,3 +116,47 @@ export async function askToOpen(sockPath, r) {
|
|
|
95
116
|
export const askAccounts = (sockPath) => ask(sockPath, 'GET', '/accounts');
|
|
96
117
|
export const askToPick = (sockPath, id) => ask(sockPath, 'PUT', '/account', { id });
|
|
97
118
|
export const askToMove = (sockPath) => ask(sockPath, 'POST', '/account/move');
|
|
119
|
+
// Each working count the server sends, until stop() or the server ends the stream; done fails when no server
|
|
120
|
+
// answers.
|
|
121
|
+
const readCount = (line) => {
|
|
122
|
+
try {
|
|
123
|
+
const working = JSON.parse(line).working;
|
|
124
|
+
return typeof working === 'number' ? working : undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
export function watchActivity(sockPath, onCount) {
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let req;
|
|
133
|
+
const done = new Promise((resolve, reject) => {
|
|
134
|
+
req = request({ socketPath: sockPath, method: 'GET', path: '/activity' }, (res) => {
|
|
135
|
+
if (res.statusCode !== 200) {
|
|
136
|
+
res.resume();
|
|
137
|
+
return reject(new Error(`server answered ${res.statusCode}`));
|
|
138
|
+
}
|
|
139
|
+
let rest = '';
|
|
140
|
+
res.setEncoding('utf8');
|
|
141
|
+
res.on('data', (chunk) => {
|
|
142
|
+
const lines = (rest + chunk).split('\n');
|
|
143
|
+
rest = lines.pop();
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
const working = readCount(line);
|
|
146
|
+
if (working !== undefined)
|
|
147
|
+
onCount(working);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
res.on('close', () => resolve());
|
|
151
|
+
});
|
|
152
|
+
req.on('error', (e) => (stopped ? resolve() : reject(e)));
|
|
153
|
+
req.end();
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
done,
|
|
157
|
+
stop: () => {
|
|
158
|
+
stopped = true;
|
|
159
|
+
req?.destroy();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export class EventHub {
|
|
2
2
|
machine;
|
|
3
|
-
|
|
3
|
+
lastSaid;
|
|
4
4
|
clients = new Set();
|
|
5
5
|
pending = null;
|
|
6
6
|
pendingHeads = null;
|
|
7
|
-
constructor(machine,
|
|
7
|
+
constructor(machine, lastSaid = async () => ({ reply: null, at: null })) {
|
|
8
8
|
this.machine = machine;
|
|
9
|
-
this.
|
|
9
|
+
this.lastSaid = lastSaid;
|
|
10
10
|
machine.onChange(() => this.schedule());
|
|
11
11
|
machine.journal.onHead(() => this.scheduleHeads());
|
|
12
12
|
}
|
|
@@ -63,8 +63,10 @@ export class EventHub {
|
|
|
63
63
|
const sessions = await this.machine.listSessions().catch(() => null);
|
|
64
64
|
if (!sessions)
|
|
65
65
|
return null;
|
|
66
|
-
const
|
|
67
|
-
|
|
66
|
+
const said = await Promise.all(sessions.map((s) => this.lastSaid(s).catch(() => null)));
|
|
67
|
+
// A session is as recent as its conversation; tmux sees only keys typed at a terminal.
|
|
68
|
+
const shown = sessions.map((s, i) => ({ ...s, lastActivity: said[i]?.at ?? s.lastActivity, lastReply: said[i]?.reply ?? null }));
|
|
69
|
+
return { type: 'sessions', machine: this.machine.id, asOf, sessions: shown };
|
|
68
70
|
}
|
|
69
71
|
close() {
|
|
70
72
|
if (this.pending)
|
|
@@ -37,13 +37,13 @@ export class Input {
|
|
|
37
37
|
// before its thread starts (trust this folder?).
|
|
38
38
|
if (this.d.store.get().sessions[id]?.kind === 'codex') {
|
|
39
39
|
const choices = this.beforeThread(id) ? codexChoices(await this.d.tmux.screen(tmuxName(id))) : null;
|
|
40
|
-
const screen = { choices, panel: null, suggestion: null, mode: null };
|
|
40
|
+
const screen = { choices, panel: null, suggestion: null, mode: null, background: false };
|
|
41
41
|
this.d.journal.screenAsIs(id, screen);
|
|
42
42
|
return { ...screen, live: null, asks: this.d.codex?.asks(id) ?? [] };
|
|
43
43
|
}
|
|
44
44
|
if (Date.now() - (this.s.macLooks.get(id) ?? 0) >= MAC_LOOK_MS) {
|
|
45
45
|
this.s.macLooks.set(id, Date.now());
|
|
46
|
-
|
|
46
|
+
this.p.lifecycle.openOnMac().catch(() => { });
|
|
47
47
|
}
|
|
48
48
|
const { screen, compacting } = await this.d.claude.read(tmuxName(id), this.s.statuses.get(id) === 'working');
|
|
49
49
|
this.p.status.compactingOnScreen(id, compacting);
|
|
@@ -69,6 +69,17 @@ export class Input {
|
|
|
69
69
|
const pressed = await this.d.claude.tick(tmuxName(id), ticked, labels);
|
|
70
70
|
return this.readAfter(id, pressed, (s) => s.choices?.options.map((o) => o.label).join('\n') !== labels.join('\n'));
|
|
71
71
|
}
|
|
72
|
+
async toBackground(id) {
|
|
73
|
+
if (!ID.test(id))
|
|
74
|
+
throw new InvalidRequest('bad session id');
|
|
75
|
+
if (this.d.store.get().sessions[id]?.kind !== 'claude')
|
|
76
|
+
return false;
|
|
77
|
+
// Answered once the screen no longer offers it, so a second press finds nothing to send.
|
|
78
|
+
if (!(await this.d.claude.toBackground(tmuxName(id))))
|
|
79
|
+
return false;
|
|
80
|
+
await this.screenOnceShown(id, (s) => !s.background, () => null).catch(() => null);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
72
83
|
// The screen as it is once a choice was answered, or found changed, goes into the journal for the phone:
|
|
73
84
|
// read now when nothing was pressed, else until the options pressed are gone.
|
|
74
85
|
readAfter(id, pressed, gone) {
|
|
@@ -145,6 +156,8 @@ export class Input {
|
|
|
145
156
|
if (!(await this.p.status.agentRuns(id)) || (await this.dialogOpen(id)))
|
|
146
157
|
return false;
|
|
147
158
|
await this.d.tmux.leaveCopyMode(tmuxName(id));
|
|
159
|
+
if (!(await this.promptCleared(id)))
|
|
160
|
+
return false;
|
|
148
161
|
await this.d.tmux.sendRaw(tmuxName(id), `${command}\r`);
|
|
149
162
|
return true;
|
|
150
163
|
}
|
|
@@ -194,10 +207,16 @@ export class Input {
|
|
|
194
207
|
const meta = this.d.store.get().sessions[id];
|
|
195
208
|
return meta?.kind === 'codex' && !meta.codexThreadId;
|
|
196
209
|
}
|
|
210
|
+
// What is already typed at Claude's prompt is put aside first: pasted after it, the two would go as one.
|
|
197
211
|
async paste(id, text) {
|
|
198
212
|
await this.d.tmux.leaveCopyMode(tmuxName(id));
|
|
213
|
+
if (!(await this.promptCleared(id)))
|
|
214
|
+
throw new Error('what is typed at the prompt would not go aside');
|
|
199
215
|
await this.d.tmux.paste(tmuxName(id), text.replace(CONTROL, ''));
|
|
200
216
|
}
|
|
217
|
+
promptCleared(id) {
|
|
218
|
+
return this.d.store.get().sessions[id]?.kind === 'claude' ? this.d.claude.clearPrompt(tmuxName(id)) : Promise.resolve(true);
|
|
219
|
+
}
|
|
201
220
|
async fitForChat(id) {
|
|
202
221
|
if (!ID.test(id))
|
|
203
222
|
throw new InvalidRequest('bad session id');
|