ticlawk 0.1.16-dev.1 → 0.1.16-dev.3

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.
@@ -2,7 +2,27 @@ import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
2
2
  import { reportSubprocessFailure, sendAdapterMessage, recordActivity, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
3
3
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
4
4
  import { GATEWAY_HOST, GATEWAY_PORT } from './identity.mjs';
5
- import { buildOpenClawSessionKey, normalizeOpenClawAgentId, resolveOpenClawWorkspace } from './target.mjs';
5
+
6
+ // Cheap availability probe used by the harness picker. We can't use
7
+ // isGatewayReady() here because the daemon only opens the gateway WS
8
+ // after at least one openclaw binding exists (registerOpenClawChannel),
9
+ // so an empty install would always look "unavailable" and you'd never
10
+ // be able to pick it for the first time. Probing the gateway's plain
11
+ // HTTP /health avoids that chicken-and-egg.
12
+ const OPENCLAW_HEALTH_TIMEOUT_MS = 1500;
13
+ async function probeOpenClawGatewayHealth() {
14
+ try {
15
+ const res = await fetch(`http://${GATEWAY_HOST}:${GATEWAY_PORT}/health`, {
16
+ signal: AbortSignal.timeout(OPENCLAW_HEALTH_TIMEOUT_MS),
17
+ });
18
+ if (!res.ok) return false;
19
+ const body = await res.json().catch(() => null);
20
+ return body?.ok === true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ import { buildOpenClawSessionKey, normalizeOpenClawAgentId } from './target.mjs';
6
26
  import { askGateway, isGatewayReady, registerOpenClawChannel } from './gateway.mjs';
7
27
 
8
28
  // Tracks which (agentId, sessionKey) pairs already saw the standing
@@ -87,18 +107,24 @@ export const openClawRuntime = {
87
107
  return isGatewayReady();
88
108
  },
89
109
 
110
+ // Health probe for the harness picker. openclaw is gateway-based —
111
+ // "available" means the local openclaw gateway is up and responds to
112
+ // an unauthenticated /health request. Doesn't depend on the daemon
113
+ // having an open WS, so the picker can offer openclaw on a fresh
114
+ // install where no binding exists yet.
115
+ async health() {
116
+ return {
117
+ available: await probeOpenClawGatewayHealth(),
118
+ path: null,
119
+ version: null,
120
+ };
121
+ },
122
+
90
123
  async resolveBinding(payload) {
91
124
  if (!payload?.agentId) {
92
125
  throw new Error('agentId is required for openclaw binding');
93
126
  }
94
127
  const agentId = normalizeOpenClawAgentId(payload.agentId);
95
- const workspace = String(
96
- payload.workdir
97
- || payload.projectDir
98
- || payload.cwd
99
- || resolveOpenClawWorkspace(agentId)
100
- || '',
101
- ).trim();
102
128
  const sessionKey = payload.sessionKey
103
129
  ? String(payload.sessionKey).trim()
104
130
  : buildOpenClawSessionKey(agentId);
@@ -110,11 +136,6 @@ export const openClawRuntime = {
110
136
  sessionKey,
111
137
  gatewayHost: GATEWAY_HOST,
112
138
  gatewayPort: GATEWAY_PORT,
113
- ...(workspace ? {
114
- workdir: workspace,
115
- projectDir: workspace,
116
- cwd: workspace,
117
- } : {}),
118
139
  },
119
140
  };
120
141
  },
@@ -5,42 +5,12 @@
5
5
  * `agent:<id>:main` session key is derived internally when dispatching.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync } from 'node:fs';
9
- import { join } from 'node:path';
10
-
11
- let cachedOpenClawConfig = null;
12
-
13
- function loadOpenClawConfig() {
14
- if (cachedOpenClawConfig !== null) return cachedOpenClawConfig;
15
- const home = process.env.HOME || '';
16
- const configPath = home ? join(home, '.openclaw', 'openclaw.json') : '';
17
- if (!configPath || !existsSync(configPath)) {
18
- cachedOpenClawConfig = {};
19
- return cachedOpenClawConfig;
20
- }
21
- try {
22
- cachedOpenClawConfig = JSON.parse(readFileSync(configPath, 'utf8')) || {};
23
- } catch {
24
- cachedOpenClawConfig = {};
25
- }
26
- return cachedOpenClawConfig;
27
- }
28
-
29
8
  export function normalizeOpenClawAgentId(value) {
30
9
  const trimmed = String(value || '').trim().toLowerCase();
31
10
  if (!trimmed) return 'main';
32
11
  return trimmed.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'main';
33
12
  }
34
13
 
35
- export function resolveOpenClawWorkspace(agentId) {
36
- const normalizedId = normalizeOpenClawAgentId(agentId);
37
- const config = loadOpenClawConfig();
38
- const agents = Array.isArray(config?.agents?.list) ? config.agents.list : [];
39
- const matched = agents.find((agent) => normalizeOpenClawAgentId(agent?.id || agent?.name || '') === normalizedId);
40
- const workspace = String(matched?.workspace || '').trim();
41
- return workspace || '';
42
- }
43
-
44
14
  export function buildOpenClawSessionKey(agentId = 'main') {
45
15
  return `agent:${normalizeOpenClawAgentId(agentId)}:main`;
46
16
  }
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { basename } from 'node:path';
12
12
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
13
13
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
14
+ import { ensureAgentHome } from '../../core/agent-home.mjs';
14
15
  import {
15
16
  createOpenCodeSession,
16
17
  getOpenCodeRuntimeHealth,
@@ -97,8 +98,6 @@ export const openCodeRuntime = {
97
98
  displayName: payload.name || session.title || basename(requestedCwd) || 'opencode',
98
99
  runtimeMeta: {
99
100
  sessionId: session.sessionId,
100
- workdir: requestedCwd,
101
- cwd: requestedCwd,
102
101
  runtimePath: opencodePath,
103
102
  opencodePath,
104
103
  opencodeVersion,
@@ -107,20 +106,11 @@ export const openCodeRuntime = {
107
106
  };
108
107
  }
109
108
 
110
- if (!requestedCwd) {
111
- throw new Error('cwd or sessionId is required for opencode binding');
112
- }
113
- if (!existsSync(requestedCwd)) {
114
- throw new Error(`opencode cwd not found locally: ${requestedCwd}`);
115
- }
116
-
117
109
  return {
118
110
  runtime: this.name,
119
- displayName: payload.name || basename(requestedCwd) || 'opencode',
111
+ displayName: payload.name || (requestedCwd ? basename(requestedCwd) : 'opencode'),
120
112
  runtimeMeta: {
121
113
  sessionId: null,
122
- workdir: requestedCwd,
123
- cwd: requestedCwd,
124
114
  runtimePath: opencodePath,
125
115
  opencodePath,
126
116
  opencodeVersion,
@@ -139,16 +129,9 @@ export const openCodeRuntime = {
139
129
  const adapter = ctx.adapter;
140
130
  const meta = binding.runtimeMeta || {};
141
131
  const runtimeOpenCodePath = meta.opencodePath || meta.runtimePath || null;
142
-
143
- if (!meta.cwd || !existsSync(meta.cwd)) {
144
- await sendAdapterMessage(adapter, binding, {
145
- type: 'assistant',
146
- text: `⚠️ opencode cwd not found: ${meta.cwd || '(missing)'}`,
147
- media: [],
148
- replyToMessageId: inbound.messageId || null,
149
- });
150
- return true;
151
- }
132
+ const agentHome = ensureAgentHome(binding.id, {
133
+ displayName: binding.display_name || binding.name || null,
134
+ });
152
135
 
153
136
  // For image inbound, resolve the attached media to local file paths
154
137
  // and forward them to opencode via `--file` (mirrors how Codex uses
@@ -195,7 +178,7 @@ export const openCodeRuntime = {
195
178
  binding,
196
179
  agent: this.name,
197
180
  sessionId: sessionId || meta.sessionId || binding.id,
198
- cwd: cwd || meta.cwd,
181
+ cwd: cwd || agentHome,
199
182
  replyToMessageId: inbound.messageId || null,
200
183
  event: {
201
184
  hook_event_name: 'worker.message.delta',
@@ -217,7 +200,7 @@ export const openCodeRuntime = {
217
200
  });
218
201
  const standingPrompt = buildStandingPrompt({ agentId: binding.id });
219
202
  const result = shouldStreamRuntime(this.name, this)
220
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, opencodePath, agentEnv }, message, {
203
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, {
221
204
  standingPrompt,
222
205
  files,
223
206
  onEvent: async (event) => {
@@ -227,7 +210,7 @@ export const openCodeRuntime = {
227
210
  binding,
228
211
  agent: this.name,
229
212
  sessionId: event.sessionId || meta.sessionId || binding.id,
230
- cwd: meta.cwd,
213
+ cwd: agentHome,
231
214
  replyToMessageId: inbound.messageId || null,
232
215
  event: {
233
216
  hook_event_name: 'worker.turn.start',
@@ -238,18 +221,17 @@ export const openCodeRuntime = {
238
221
  } else if (event?.type === 'message.delta' && event.text) {
239
222
  deltaAggregator.push(event.text, {
240
223
  sessionId: event.sessionId || meta.sessionId || binding.id,
241
- cwd: meta.cwd,
224
+ cwd: agentHome,
242
225
  });
243
226
  }
244
227
  },
245
228
  })
246
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, opencodePath, agentEnv }, message, { files, standingPrompt });
229
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, { files, standingPrompt });
247
230
 
248
231
  await deltaAggregator.flush();
249
232
 
250
233
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
251
234
  sessionId: result?.sessionId || meta.sessionId,
252
- cwd: result?.cwd || meta.cwd,
253
235
  runtimePath: opencodePath,
254
236
  opencodePath,
255
237
  opencodeVersion,
@@ -265,7 +247,7 @@ export const openCodeRuntime = {
265
247
  binding: nextBinding,
266
248
  agent: this.name,
267
249
  sessionId: result?.sessionId || meta.sessionId || binding.id,
268
- cwd: result?.cwd || meta.cwd,
250
+ cwd: result?.cwd || agentHome,
269
251
  replyToMessageId: inbound.messageId || null,
270
252
  event: {
271
253
  hook_event_name: 'Stop',
@@ -281,7 +263,7 @@ export const openCodeRuntime = {
281
263
  binding,
282
264
  agent: this.name,
283
265
  sessionId: meta.sessionId || binding.id,
284
- cwd: meta.cwd,
266
+ cwd: agentHome,
285
267
  replyToMessageId: inbound.messageId || null,
286
268
  event: {
287
269
  hook_event_name: 'worker.turn.error',
@@ -313,7 +295,7 @@ export const openCodeRuntime = {
313
295
  binding,
314
296
  agent: this.name,
315
297
  sessionId: meta.sessionId || binding.id,
316
- cwd: meta.cwd || '',
298
+ cwd: ensureAgentHome(binding.id) || '',
317
299
  event: {
318
300
  hook_event_name: 'Stop',
319
301
  worker_event_name: 'worker.turn.complete',
@@ -4,10 +4,10 @@
4
4
  * Wraps the pi CLI RPC mode and exposes the ticlawk runtime contract.
5
5
  */
6
6
 
7
- import { existsSync } from 'node:fs';
8
7
  import { basename } from 'node:path';
9
8
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
10
9
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
10
+ import { ensureAgentHome } from '../../core/agent-home.mjs';
11
11
  import {
12
12
  buildPiImagesFromInbound,
13
13
  discoverPiSessions,
@@ -80,8 +80,6 @@ export const piRuntime = {
80
80
  displayName: payload.name || basename(session.cwd || requestedCwd) || 'pi',
81
81
  runtimeMeta: {
82
82
  sessionId: session.sessionId,
83
- workdir: session.cwd || requestedCwd,
84
- cwd: session.cwd || requestedCwd,
85
83
  path: session.path || null,
86
84
  runtimePath: piPath,
87
85
  piPath,
@@ -91,20 +89,11 @@ export const piRuntime = {
91
89
  };
92
90
  }
93
91
 
94
- if (!requestedCwd) {
95
- throw new Error('cwd or sessionId is required for pi binding');
96
- }
97
- if (!existsSync(requestedCwd)) {
98
- throw new Error(`pi cwd not found locally: ${requestedCwd}`);
99
- }
100
-
101
92
  return {
102
93
  runtime: this.name,
103
- displayName: payload.name || basename(requestedCwd) || 'pi',
94
+ displayName: payload.name || (requestedCwd ? basename(requestedCwd) : 'pi'),
104
95
  runtimeMeta: {
105
96
  sessionId: null,
106
- workdir: requestedCwd,
107
- cwd: requestedCwd,
108
97
  path: null,
109
98
  runtimePath: piPath,
110
99
  piPath,
@@ -123,16 +112,9 @@ export const piRuntime = {
123
112
  if (!binding) return false;
124
113
  const adapter = ctx.adapter;
125
114
  const meta = binding.runtimeMeta || {};
126
-
127
- if (!meta.cwd || !existsSync(meta.cwd)) {
128
- await sendAdapterMessage(adapter, binding, {
129
- type: 'assistant',
130
- text: `⚠️ pi cwd not found: ${meta.cwd || '(missing)'}`,
131
- media: [],
132
- replyToMessageId: inbound.messageId || null,
133
- });
134
- return true;
135
- }
115
+ const agentHome = ensureAgentHome(binding.id, {
116
+ displayName: binding.display_name || binding.name || null,
117
+ });
136
118
 
137
119
  let images = [];
138
120
  let message = inbound.text || '';
@@ -167,7 +149,7 @@ export const piRuntime = {
167
149
  binding,
168
150
  agent: this.name,
169
151
  sessionId: sessionId || meta.sessionId || binding.id,
170
- cwd: cwd || meta.cwd,
152
+ cwd: cwd || agentHome,
171
153
  replyToMessageId: inbound.messageId || null,
172
154
  event: {
173
155
  hook_event_name: 'worker.message.delta',
@@ -189,7 +171,7 @@ export const piRuntime = {
189
171
  });
190
172
  const standingPrompt = buildStandingPrompt({ agentId: binding.id });
191
173
  const result = shouldStreamRuntime(this.name, this)
192
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath, agentEnv }, message, {
174
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, {
193
175
  standingPrompt,
194
176
  images,
195
177
  onEvent: async (event) => {
@@ -199,7 +181,7 @@ export const piRuntime = {
199
181
  binding,
200
182
  agent: this.name,
201
183
  sessionId: event.sessionId || meta.sessionId || binding.id,
202
- cwd: meta.cwd,
184
+ cwd: agentHome,
203
185
  replyToMessageId: inbound.messageId || null,
204
186
  event: {
205
187
  hook_event_name: 'worker.turn.start',
@@ -210,17 +192,16 @@ export const piRuntime = {
210
192
  } else if (event?.type === 'message.delta' && event.text) {
211
193
  deltaAggregator.push(event.text, {
212
194
  sessionId: event.sessionId || meta.sessionId || binding.id,
213
- cwd: meta.cwd,
195
+ cwd: agentHome,
214
196
  });
215
197
  }
216
198
  },
217
199
  })
218
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
200
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
219
201
 
220
202
  await deltaAggregator.flush();
221
203
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
222
204
  sessionId: result?.sessionId || meta.sessionId,
223
- cwd: result?.cwd || meta.cwd,
224
205
  path: result?.path || meta.path || null,
225
206
  runtimePath: runtimePiPath,
226
207
  piPath: runtimePiPath,
@@ -234,7 +215,7 @@ export const piRuntime = {
234
215
  binding: nextBinding,
235
216
  agent: this.name,
236
217
  sessionId: result?.sessionId || meta.sessionId || binding.id,
237
- cwd: result?.cwd || meta.cwd,
218
+ cwd: result?.cwd || agentHome,
238
219
  replyToMessageId: inbound.messageId || null,
239
220
  event: {
240
221
  hook_event_name: 'Stop',
@@ -250,7 +231,7 @@ export const piRuntime = {
250
231
  binding,
251
232
  agent: this.name,
252
233
  sessionId: meta.sessionId || binding.id,
253
- cwd: meta.cwd,
234
+ cwd: agentHome,
254
235
  replyToMessageId: inbound.messageId || null,
255
236
  event: {
256
237
  hook_event_name: 'worker.turn.error',
@@ -282,7 +263,7 @@ export const piRuntime = {
282
263
  binding,
283
264
  agent: this.name,
284
265
  sessionId: meta.sessionId || binding.id,
285
- cwd: meta.cwd || '',
266
+ cwd: ensureAgentHome(binding.id) || '',
286
267
  event: {
287
268
  hook_event_name: 'Stop',
288
269
  worker_event_name: 'worker.turn.complete',
package/ticlawk.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  persistConfig,
22
22
  } from './src/core/config.mjs';
23
23
  import { startLocalHttpServer } from './src/core/http.mjs';
24
+ import { startReminderTicker } from './src/core/reminder-ticker.mjs';
24
25
  import { installProcessDiagnostics } from './src/core/diagnostics.mjs';
25
26
  import * as logger from './src/core/logger.mjs';
26
27
  import { Bus } from './src/core/bus.mjs';
@@ -30,6 +31,28 @@ import { buildRuntimeContext, normalizeServiceType } from './src/core/runtime-re
30
31
  import { belongsToRuntimeHost, getBindingRuntimeHostId, getHostId } from './src/core/host-id.mjs';
31
32
  import { readPkgVersion } from './src/core/update.mjs';
32
33
  import { buildImageMessageFromInbound } from './src/core/media/inbound.mjs';
34
+ import { existsSync as _fsExists, unlinkSync as _fsUnlink } from 'node:fs';
35
+ import { join as _pathJoin } from 'node:path';
36
+ import { getActiveProfile } from './src/core/profiles.mjs';
37
+
38
+ // Pre-profile builds wrote bindings to ~/.ticlawk/bindings.json. The
39
+ // profile flow now owns binding persistence under
40
+ // ~/.ticlawk/profiles/<adapter>/<userId>/bindings.json. The root file
41
+ // hasn't had a writer for a while but lingers as stale state. Prune
42
+ // once per daemon start so it doesn't confuse `jq` audits later.
43
+ function pruneLegacyRootBindings() {
44
+ if (!getActiveProfile()) return;
45
+ const rootPath = _pathJoin(AF_HOME, 'bindings.json');
46
+ if (!_fsExists(rootPath)) return;
47
+ try {
48
+ _fsUnlink(rootPath);
49
+ logger.debugLog?.('startup', 'legacy-root-bindings.pruned', { path: rootPath });
50
+ } catch (err) {
51
+ logger.debugError?.('startup', 'legacy-root-bindings.prune-failed', {
52
+ path: rootPath, error: err?.message || String(err),
53
+ });
54
+ }
55
+ }
33
56
 
34
57
  // Re-export the config-owned paths so local tooling can inspect the
35
58
  // ticlawk home/config/log locations without reaching into src/.
@@ -80,7 +103,7 @@ function createUpsertBindingWithSync(runtimes, adapter) {
80
103
  };
81
104
  }
82
105
 
83
- function createCacheBinding(runtimes, getAdapter) {
106
+ function createPersistBinding(runtimes, getAdapter) {
84
107
  return async (binding) => {
85
108
  const nextBinding = await upsertBinding(binding);
86
109
  if (!belongsToRuntimeHost(nextBinding)) {
@@ -101,13 +124,13 @@ function createCacheBinding(runtimes, getAdapter) {
101
124
  };
102
125
  }
103
126
 
104
- function createBaseRuntimeCtx(runtimes, cacheBinding, upsertBindingWithSync) {
127
+ function createBaseRuntimeCtx(runtimes, persistBinding, upsertBindingWithSync) {
105
128
  return {
106
129
  runtimes,
107
130
  getBinding,
108
131
  listBindings,
109
132
  deleteBinding,
110
- cacheBinding,
133
+ persistBinding,
111
134
  upsertBinding: upsertBindingWithSync,
112
135
  buildImageMessageFromInbound,
113
136
  logger,
@@ -221,11 +244,12 @@ export async function startTiclawk() {
221
244
  if (started) return;
222
245
  started = true;
223
246
  installProcessDiagnostics();
247
+ pruneLegacyRootBindings();
224
248
 
225
249
  const { runtimeList, runtimes } = await buildRuntimeContext();
226
250
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
227
251
  let adapter;
228
- const cacheBinding = createCacheBinding(runtimes, () => adapter);
252
+ const persistBinding = createPersistBinding(runtimes, () => adapter);
229
253
  let baseRuntimeCtx;
230
254
  let syncBinding = async (binding) => {
231
255
  if (!adapter) {
@@ -233,13 +257,13 @@ export async function startTiclawk() {
233
257
  }
234
258
  return upsertBinding(binding);
235
259
  };
236
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
260
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
237
261
  adapter = createAdapter(
238
262
  'ticlawk',
239
263
  createAdapterContext(baseRuntimeCtx, resolveRuntimeBinding)
240
264
  );
241
265
  syncBinding = createUpsertBindingWithSync(runtimes, adapter);
242
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
266
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
243
267
 
244
268
  printBanner(adapter);
245
269
  if (typeof adapter.refreshBindings === 'function') {
@@ -252,6 +276,7 @@ export async function startTiclawk() {
252
276
  adapter,
253
277
  ctx: { listBindings, getBinding },
254
278
  });
279
+ startReminderTicker();
255
280
  await recoverAllRuntimes(runtimeList, adapter);
256
281
  await reconcileBindingsAfterRestart(runtimes, adapter);
257
282
  await adapter.start();