@nowcrew/daemon 0.6.98-beta.3 → 0.6.98-beta.5

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.
@@ -17,6 +17,12 @@ import { discoverExactLegacyService, } from "./automatic-service-migration.js";
17
17
  import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
18
18
  import { serverHostIdentity } from "./server-url.js";
19
19
  import { computerSetupRecoveryGuidance } from "./computer-setup-client.js";
20
+ export const automaticDaemonInstallStageMessage = {
21
+ downloading: "Downloading the Daemon package...",
22
+ configuring: "Configuring the Daemon service...",
23
+ starting: "Starting the Daemon service...",
24
+ confirming: "Confirming the computer connection...",
25
+ };
20
26
  function profileStorage(platform, userHome, runner) {
21
27
  return {
22
28
  platform,
@@ -331,6 +337,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
331
337
  });
332
338
  let installFailure;
333
339
  try {
340
+ input.onProgress?.("downloading");
334
341
  await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner, target.registryUrl);
335
342
  }
336
343
  catch (error) {
@@ -353,6 +360,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
353
360
  legacyRemovalStarted = true;
354
361
  await dependencies.removeLegacyService(legacyService);
355
362
  }
363
+ input.onProgress?.("configuring");
356
364
  profileSaved = true;
357
365
  await dependencies.saveTargetProfile({
358
366
  version: 1,
@@ -367,6 +375,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
367
375
  }, target.daemonHome);
368
376
  serviceReplacementStarted = true;
369
377
  await dependencies.replaceService(spec, target);
378
+ input.onProgress?.("starting");
370
379
  await dependencies.startService(spec);
371
380
  const status = await dependencies.verifyService(spec);
372
381
  if (!status.running)
@@ -380,6 +389,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
380
389
  daemonHome: target.daemonHome,
381
390
  agentsRoot: target.agentsRoot,
382
391
  };
392
+ input.onProgress?.("confirming");
383
393
  await input.verifyAfterStart?.(result);
384
394
  return result;
385
395
  }
File without changes
@@ -52,6 +52,13 @@ const SetupTokenTargetSchema = SetupBootstrapSchema.omit({ setupId: true, machin
52
52
  credentialMatchesConnection: z.boolean(),
53
53
  }).strict(),
54
54
  }).strict();
55
+ const SetupReceiptSchema = z.object({
56
+ setupId: z.string().uuid(),
57
+ credentialState: z.enum(["pending", "finalized"]),
58
+ target: SetupTokenTargetSchema.omit({ connection: true }),
59
+ connection: SetupTokenTargetSchema.shape.connection,
60
+ connectionConfirmed: z.boolean(),
61
+ }).strict();
55
62
  const SetupPendingSchema = z.object({
56
63
  status: z.enum(["authorization_pending", "pending"]),
57
64
  intervalSeconds: z.number().int().positive().max(300).optional(),
@@ -89,7 +96,8 @@ export class ComputerSetupHttpError extends Error {
89
96
  }
90
97
  }
91
98
  export function redactSetupSecrets(value, secrets = []) {
92
- let redacted = value.replace(/\bsk_machine_[A-Za-z0-9_-]+\b/gu, "<redacted>");
99
+ let redacted = value.replace(/\bsk_machine_[A-Za-z0-9_-]+\b/gu, "<redacted>")
100
+ .replace(/(--setup-token(?:=|\s+))[A-Za-z0-9_-]+/gu, "$1<redacted>");
93
101
  for (const secret of secrets) {
94
102
  if (secret.length > 0)
95
103
  redacted = redacted.replaceAll(secret, "<redacted>");
@@ -251,7 +259,8 @@ export function createComputerSetupClient(options) {
251
259
  }));
252
260
  const now = options.now ?? Date.now;
253
261
  const request = async (method, path, body, bearer) => {
254
- const secretInputs = [bearer ?? "", body?.deviceCode ?? ""];
262
+ const secretInputs = [bearer ?? "", body?.deviceCode ?? "",
263
+ body?.setupToken ?? ""];
255
264
  const controller = new AbortController();
256
265
  const timeoutId = setTimeout(() => controller.abort(), requestTimeoutMs);
257
266
  try {
@@ -283,6 +292,12 @@ export function createComputerSetupClient(options) {
283
292
  }
284
293
  };
285
294
  return {
295
+ receipt: async ({ setupToken, machineToken }) => {
296
+ const receipt = SetupReceiptSchema.safeParse(await request("POST", "/daemon/computer-setup/receipt", { setupToken }, machineToken));
297
+ if (!receipt.success)
298
+ throw new Error("Invalid computer setup receipt response");
299
+ return receipt.data;
300
+ },
286
301
  tokenTarget: async (machineToken) => SetupTokenTargetSchema.parse(await request("GET", "/daemon/computer-setup/target", undefined, machineToken)),
287
302
  start: async (input) => {
288
303
  return parseStart(await request("POST", "/api/auth/computer-setup/start", {
@@ -0,0 +1,154 @@
1
+ import { access, readFile, readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { resolveAutomaticInstallTarget } from "./automatic-install-target.js";
5
+ import { stableDaemonEntry } from "./automatic-daemon-installer.js";
6
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
7
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
8
+ import { isDaemonReleaseVersion } from "./daemon-release-version.js";
9
+ import { listProfiles, loadProfile, profileIsPrivate, profilePath } from "./computer-profile.js";
10
+ import { buildServiceSpec, captureExactService, systemCommandRunner, windowsProfileIsPrivate } from "./computer-service.js";
11
+ import { buildManagedServiceRecord } from "./managed-service-lifecycle.js";
12
+ import { managedServiceIdentityMatches, readManagedServiceRegistry } from "./managed-service-registry.js";
13
+ import { serverUrlsMatch } from "./server-url.js";
14
+ import { assertBootstrapMatches, localPathsMatch } from "./computer-setup-orchestration.js";
15
+ const storageKeys = ["NOWWORK_HOME", "NOWWORK_ENV_TAG", "NOWWORK_STORAGE_POLICY_PATH", "NOWWORK_LOCAL_HOME", "NOWWORK_LOCAL_HOME_MODE"];
16
+ const decodeXml = (value) => value.replaceAll("&quot;", '"').replaceAll("&apos;", "'")
17
+ .replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
18
+ // Only recover the generated environment fields; the rebuilt descriptor must still match byte-for-byte.
19
+ function descriptorEnvironment(descriptor, platform) {
20
+ return Object.fromEntries(storageKeys.flatMap((key) => {
21
+ const value = platform === "darwin"
22
+ ? new RegExp(`<key>${key}</key><string>([^<]*)</string>`, "u").exec(descriptor)?.[1]
23
+ : new RegExp(`^Environment="${key}=((?:[^"\\\\]|\\\\.)*)"$`, "mu").exec(descriptor)?.[1];
24
+ return value === undefined ? [] : [[key, platform === "darwin" ? decodeXml(value) : value.replace(/\\(.)/gu, "$1").replaceAll("%%", "%")]];
25
+ }));
26
+ }
27
+ async function windowsNodePath(serviceId, runner) {
28
+ const script = "$ErrorActionPreference='Stop';$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));"
29
+ + "$t=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});"
30
+ + "if($t.Count -ne 1){throw 'scheduled task identity unknown'};$a=@($t[0].Actions);"
31
+ + "if($a.Count -ne 1){throw 'scheduled task action unknown'};$a[0].Execute|ConvertTo-Json -Compress;";
32
+ const result = await runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script,
33
+ Buffer.from(serviceId, "utf16le").toString("base64")]);
34
+ const path = result.exitCode === 0 ? JSON.parse(result.stdout) : null;
35
+ if (typeof path !== "string" || !win32.isAbsolute(path))
36
+ throw new Error("Scheduled task executable unavailable");
37
+ return path;
38
+ }
39
+ /** One-shot inspection only. Never consumes credentials or changes local/service state. */
40
+ export async function inspectExistingComputerSetup(input, client, options = {}) {
41
+ if (input.args.setupToken === undefined)
42
+ return { status: "unavailable" };
43
+ const platform = options.platform ?? process.platform;
44
+ const userHome = options.userHome ?? homedir();
45
+ const environment = options.environment ?? process.env;
46
+ const runner = options.runner ?? systemCommandRunner;
47
+ const paths = platform === "win32" ? win32 : posix;
48
+ try {
49
+ const requestedLocalHome = environment.NOWWORK_LOCAL_HOME;
50
+ const requestedLocalHomeMode = environment.NOWWORK_LOCAL_HOME_MODE ?? environment.NOWWORK_STORAGE_MODE;
51
+ const localOptions = {
52
+ ...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
53
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
54
+ };
55
+ const configuredHome = resolveDaemonLocalHome({ userHome, platform, profile: "in", mode: "nowwork",
56
+ ...(requestedLocalHome === undefined ? {} : { explicit: requestedLocalHome }) }).daemonHome;
57
+ const legacyRoot = paths.join(userHome, ".crew");
58
+ const legacyEntries = await readdir(legacyRoot, { withFileTypes: true }).catch((error) => {
59
+ if (error.code === "ENOENT")
60
+ return [];
61
+ throw error;
62
+ });
63
+ const homes = [...new Set([configuredHome, paths.join(userHome, ".nowwork", "config"),
64
+ ...legacyEntries.filter((entry) => entry.isDirectory() && /^daemon(?:-[a-z0-9_-]+)?$/u.test(entry.name))
65
+ .map((entry) => paths.join(legacyRoot, entry.name))])];
66
+ const candidates = [];
67
+ for (const home of homes) {
68
+ for (const name of await listProfiles(home)) {
69
+ const privateProfile = platform === "win32"
70
+ ? await windowsProfileIsPrivate(profilePath(name, home), runner)
71
+ : await profileIsPrivate(name, home, platform);
72
+ if (!privateProfile)
73
+ continue;
74
+ const profile = await loadProfile(name, home, { platform });
75
+ if (profile.name !== name || profile.machineId !== input.args.machineId || profile.workspaceId === undefined
76
+ || !serverUrlsMatch(profile.serverUrl, input.serverUrl)
77
+ || (input.args.workspace.kind === "id" && profile.workspaceId !== input.args.workspace.value))
78
+ continue;
79
+ const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: name,
80
+ userHome, platform, ...localOptions });
81
+ if (!localPathsMatch(target.daemonHome, home, platform))
82
+ continue;
83
+ if (profile.agentsRoot === undefined || !localPathsMatch(profile.agentsRoot, target.agentsRoot, platform))
84
+ continue;
85
+ candidates.push({ profile, target });
86
+ }
87
+ }
88
+ if (candidates.length !== 1)
89
+ return { status: "unavailable" };
90
+ const { profile, target } = candidates[0];
91
+ const receipt = await client.receipt({ setupToken: input.args.setupToken, machineToken: profile.machineToken });
92
+ assertBootstrapMatches(receipt.target, input);
93
+ if (receipt.target.workspaceId !== profile.workspaceId || receipt.target.profile !== profile.name)
94
+ return { status: "unavailable" };
95
+ if (receipt.credentialState === "pending")
96
+ return { status: "pending" };
97
+ const connection = receipt.connection;
98
+ if (receipt.credentialState !== "finalized" || !receipt.connectionConfirmed || connection.status !== "online"
99
+ || !connection.credentialMatchesConnection || connection.epoch <= receipt.target.minimumConnectionEpoch
100
+ || connection.profile !== profile.name || connection.daemonHome === null || connection.agentsRoot === null
101
+ || !localPathsMatch(connection.daemonHome, target.daemonHome, platform)
102
+ || !localPathsMatch(connection.agentsRoot, target.agentsRoot, platform)
103
+ || (receipt.target.storageId !== undefined && receipt.target.storageId !== connection.storageId))
104
+ return { status: "unavailable" };
105
+ const localHome = paths.resolve(target.npmPrefix, "..", "..");
106
+ const entryPath = stableDaemonEntry(target, platform);
107
+ const installation = daemonGlobalInstallation(entryPath, platform);
108
+ if (installation === null)
109
+ return { status: "unavailable" };
110
+ await access(entryPath);
111
+ const packageJson = JSON.parse(await readFile(paths.join(installation.packageRoot, "package.json"), "utf8"));
112
+ if (!packageJson || typeof packageJson !== "object" || !("name" in packageJson) || packageJson.name !== "@nowcrew/daemon"
113
+ || !("version" in packageJson) || typeof packageJson.version !== "string" || !isDaemonReleaseVersion(packageJson.version))
114
+ return { status: "unavailable" };
115
+ const base = { platform, profile: profile.name, profileHome: target.daemonHome, userHome,
116
+ uid: options.uid ?? process.getuid?.(), entryPath };
117
+ let spec;
118
+ if (platform === "win32") {
119
+ const identity = buildServiceSpec({ ...base, nodePath: process.execPath, environment: {} });
120
+ const nodePath = await windowsNodePath(identity.id, runner);
121
+ await access(nodePath);
122
+ spec = buildServiceSpec({ ...base, nodePath, environment: {} });
123
+ }
124
+ else {
125
+ const records = (await readManagedServiceRegistry({ userHome, localHome })).filter((record) => record.profile === profile.name
126
+ && localPathsMatch(record.daemonHome, target.daemonHome, platform));
127
+ if (records.length !== 1)
128
+ return { status: "unavailable" };
129
+ const record = records[0];
130
+ await access(record.nodePath);
131
+ const descriptor = await readFile(record.descriptorPath, "utf8");
132
+ spec = buildServiceSpec({ ...base, nodePath: record.nodePath, entryPath: record.entryPath,
133
+ environment: descriptorEnvironment(descriptor, platform) });
134
+ const expected = buildManagedServiceRecord({ spec, daemonHome: target.daemonHome, agentsRoot: target.agentsRoot,
135
+ nodePath: record.nodePath, entryPath, ...installation });
136
+ if (!managedServiceIdentityMatches(record, expected))
137
+ return { status: "unavailable" };
138
+ }
139
+ const service = await captureExactService(spec, runner);
140
+ if (!service.installed || !service.exact || !service.running)
141
+ return { status: "unavailable" };
142
+ return { status: "connected", result: {
143
+ workspaceId: receipt.target.workspaceId, workspaceSlug: receipt.target.workspaceSlug, machineId: receipt.target.machineId,
144
+ environment: receipt.target.environment, profile: profile.name, daemonVersion: packageJson.version,
145
+ serviceId: spec.id, serviceStatus: "running", helloConfirmed: true, localNowworkHome: localHome,
146
+ daemonHome: target.daemonHome, agentsRoot: target.agentsRoot,
147
+ ...(connection.storageId === undefined ? {} : { storageId: connection.storageId }),
148
+ } };
149
+ }
150
+ catch {
151
+ // Failed inspection is not proof of installation, connection, or credential ownership.
152
+ return { status: "unavailable" };
153
+ }
154
+ }
@@ -1,10 +1,12 @@
1
1
  import { computerSetupRecoveryGuidance, redactSetupSecrets, } from "./computer-setup-client.js";
2
+ import { automaticDaemonInstallStageMessage } from "./automatic-daemon-installer.js";
3
+ import { inspectExistingComputerSetup } from "./computer-setup-existing.js";
2
4
  import { isProductionServerHostname, serverUrlsMatch } from "./server-url.js";
3
5
  import { posix, win32 } from "node:path";
4
6
  import { resolveAgentsRoot } from "./computer-profile.js";
5
- function safeError(error, machineToken) {
6
- const message = redactSetupSecrets(error instanceof Error ? error.message : String(error), machineToken === undefined ? [] : [machineToken]);
7
- return new Error(message, error instanceof Error ? { cause: error } : undefined);
7
+ function safeError(error, machineToken, setupToken) {
8
+ const message = redactSetupSecrets(error instanceof Error ? error.message : String(error), [machineToken ?? "", setupToken ?? ""]);
9
+ return new Error(message);
8
10
  }
9
11
  export function assertBootstrapMatches(bootstrap, input) {
10
12
  if (bootstrap.machineId !== input.args.machineId) {
@@ -55,6 +57,18 @@ async function obtainBootstrap(input, deps) {
55
57
  deps.reportPairing("Checking computer connection information...");
56
58
  const result = await deps.client.poll(input.args.setupToken);
57
59
  if (result.status !== "authorized") {
60
+ deps.reportPairing("Checking the existing computer connection...");
61
+ const inspection = await (deps.inspectExistingSetup ?? ((candidate, client) => inspectExistingComputerSetup(candidate, client, {
62
+ ...(deps.platform === undefined ? {} : { platform: deps.platform }),
63
+ ...(deps.environment === undefined ? {} : { environment: deps.environment }),
64
+ })))(input, deps.client);
65
+ if (inspection.status === "connected") {
66
+ deps.reportPairing("This computer is already connected. No installation changes are needed.");
67
+ return inspection.result;
68
+ }
69
+ if (inspection.status === "pending") {
70
+ throw new Error("Setup is still in progress; check the original terminal and wait for it to finish");
71
+ }
58
72
  throw new Error("Setup command expired or unavailable; regenerate it in NowWork and run the new command");
59
73
  }
60
74
  return result.bootstrap;
@@ -120,12 +134,14 @@ export async function runComputerSetupOrchestration(input, deps) {
120
134
  };
121
135
  try {
122
136
  const authorized = await obtainBootstrap(input, deps);
137
+ if ("helloConfirmed" in authorized)
138
+ return authorized;
123
139
  const token = authorized.machineToken;
124
140
  bootstrap = authorized;
125
141
  machineToken = token;
126
142
  assertBootstrapMatches(authorized, input);
127
- deps.reportPairing("Downloading, installing, and starting the Daemon service...");
128
143
  const installation = await deps.installAutomatically({
144
+ onProgress: (stage) => deps.reportPairing(automaticDaemonInstallStageMessage[stage]),
129
145
  serverUrl: authorized.serverUrl,
130
146
  machineToken: token,
131
147
  workspaceId: authorized.workspaceId,
@@ -137,7 +153,6 @@ export async function runComputerSetupOrchestration(input, deps) {
137
153
  ...(authorized.registryUrl === undefined ? {} : { requestedRegistryUrl: authorized.registryUrl }),
138
154
  beforeRollback: confirmRollback,
139
155
  verifyAfterStart: async (candidate) => {
140
- deps.reportPairing("Confirming the computer connection...");
141
156
  const hello = await deps.client.waitForHello({
142
157
  machineToken: token,
143
158
  machineId: authorized.machineId,
@@ -209,6 +224,6 @@ export async function runComputerSetupOrchestration(input, deps) {
209
224
  + `local recovery blocked. ${computerSetupRecoveryGuidance(bootstrap)}`, { cause: error });
210
225
  }
211
226
  }
212
- throw safeError(error, machineToken);
227
+ throw safeError(error, machineToken, input.args.setupToken);
213
228
  }
214
229
  }
@@ -258,6 +258,7 @@ export async function runComputerSetupCommand(argv, overrides = {}) {
258
258
  reportPairing: (message) => deps[args.json === true ? "stderr" : "stdout"](`${localizedPairingMessage(lang, message)}\n`),
259
259
  now: deps.now,
260
260
  platform: deps.platform,
261
+ environment: deps.env,
261
262
  };
262
263
  const result = args.machineToken === undefined
263
264
  ? await runComputerSetupOrchestration({ args, serverUrl }, orchestrationDeps)
@@ -1,3 +1,4 @@
1
+ import { automaticDaemonInstallStageMessage } from "./automatic-daemon-installer.js";
1
2
  import { assertBootstrapMatches, localPathsMatch } from "./computer-setup-orchestration.js";
2
3
  import { redactSetupSecrets } from "./computer-setup-client.js";
3
4
  export async function runMachineTokenSetup(input, deps, machineToken) {
@@ -5,14 +6,13 @@ export async function runMachineTokenSetup(input, deps, machineToken) {
5
6
  deps.reportPairing("Checking computer connection information...");
6
7
  const target = await deps.client.tokenTarget(machineToken);
7
8
  assertBootstrapMatches(target, input);
8
- deps.reportPairing("Downloading, installing, and starting the Daemon service...");
9
9
  const installation = await deps.installAutomatically({
10
+ onProgress: (stage) => deps.reportPairing(automaticDaemonInstallStageMessage[stage]),
10
11
  serverUrl: target.serverUrl, machineToken, workspaceId: target.workspaceId, machineId: target.machineId,
11
12
  requestedProfile: target.profile,
12
13
  ...(target.releaseTag === undefined ? {} : { requestedReleaseTag: target.releaseTag }),
13
14
  ...(target.registryUrl === undefined ? {} : { requestedRegistryUrl: target.registryUrl }),
14
15
  verifyAfterStart: async (candidate) => {
15
- deps.reportPairing("Confirming the computer connection...");
16
16
  const deadline = deps.now() + 120_000;
17
17
  for (;;) {
18
18
  const current = await deps.client.tokenTarget(machineToken);
@@ -587,6 +587,26 @@ export async function runExecution(config, input, dependencies) {
587
587
  let activitySequence = 0;
588
588
  let consoleSequence = 0;
589
589
  let externalOutputSequence = 0;
590
+ const bufferedPiExternalOutput = [];
591
+ const reportExternalOutput = (text) => {
592
+ if (firstOutputTimer !== undefined) {
593
+ clearTimeout(firstOutputTimer);
594
+ firstOutputTimer = undefined;
595
+ }
596
+ const frame = DaemonToServerExecutionFrameSchema.parse({
597
+ type: "execution:output",
598
+ protocolVersion: 1,
599
+ executionId: spec.executionId,
600
+ channel: "external_answer",
601
+ text,
602
+ seq: externalOutputSequence++,
603
+ at: now().toISOString(),
604
+ });
605
+ try {
606
+ telemetry.enqueue(boundExecutionFrame(frame, config.executionLimits.maxEventBytes));
607
+ }
608
+ catch { /* final completion remains the authoritative repair */ }
609
+ };
590
610
  const callbacks = {
591
611
  onRuntimeStarting: () => {
592
612
  dependencies.onRuntimePhase?.("starting", spec.runtime.name);
@@ -711,23 +731,11 @@ export async function runExecution(config, input, dependencies) {
711
731
  } : {}),
712
732
  ...(spec.context.externalResponseSessionId || spec.context.answerStream ? {
713
733
  onExternalOutput: (text) => {
714
- if (firstOutputTimer !== undefined) {
715
- clearTimeout(firstOutputTimer);
716
- firstOutputTimer = undefined;
717
- }
718
- const frame = DaemonToServerExecutionFrameSchema.parse({
719
- type: "execution:output",
720
- protocolVersion: 1,
721
- executionId: spec.executionId,
722
- channel: "external_answer",
723
- text,
724
- seq: externalOutputSequence++,
725
- at: now().toISOString(),
726
- });
727
- try {
728
- telemetry.enqueue(boundExecutionFrame(frame, config.executionLimits.maxEventBytes));
734
+ if (spec.runtime.name === "pi") {
735
+ bufferedPiExternalOutput.push(text);
736
+ return;
729
737
  }
730
- catch { /* final completion remains the authoritative repair */ }
738
+ reportExternalOutput(text);
731
739
  },
732
740
  } : {}),
733
741
  };
@@ -909,6 +917,10 @@ export async function runExecution(config, input, dependencies) {
909
917
  clearTimeout(timeout);
910
918
  if (firstOutputTimer !== undefined)
911
919
  clearTimeout(firstOutputTimer);
920
+ if (spec.runtime.name === "pi" && result.exitCode === 0 && result.externalAnswer !== null) {
921
+ for (const text of bufferedPiExternalOutput)
922
+ reportExternalOutput(text);
923
+ }
912
924
  const finishedAt = now().toISOString();
913
925
  if (result.exitCode === 0 && result.finalText?.trim())
914
926
  memoryCaptureFinalText = result.finalText;
@@ -110,6 +110,7 @@ export class ExternalAnswerDecoder {
110
110
  pending = "";
111
111
  block = "";
112
112
  closed = null;
113
+ rejected = false;
113
114
  push(text) {
114
115
  if (!text)
115
116
  return [];
@@ -125,7 +126,7 @@ export class ExternalAnswerDecoder {
125
126
  }
126
127
  /** 整轮里最后一个完整闭合的标签块(已 trim);没有闭合过任何块则为 null。 */
127
128
  get lastClosedBlock() {
128
- return this.closed;
129
+ return this.rejected ? null : this.closed;
129
130
  }
130
131
  consume(value, output) {
131
132
  this.pending = "";
@@ -154,12 +155,21 @@ export class ExternalAnswerDecoder {
154
155
  this.block = "";
155
156
  this.state = "inside";
156
157
  }
158
+ else if (!tag.closing && this.state === "inside") {
159
+ // 嵌套答案标记说明模型正在讨论协议或混入了思考文本;
160
+ // 不要把内层短片段误当成最终答案。
161
+ this.rejected = true;
162
+ this.block = "";
163
+ this.state = "outside";
164
+ }
157
165
  else {
166
+ this.rejected = true;
158
167
  this.block = "";
159
168
  this.state = tag.closing ? "outside" : "inside";
160
169
  }
161
170
  }
162
171
  else if (tag.kind === "suspicious" || tag.kind === "known") {
172
+ this.rejected = true;
163
173
  this.block = "";
164
174
  this.state = "outside";
165
175
  }
@@ -171,6 +181,7 @@ export class ExternalAnswerDecoder {
171
181
  else if (this.pending.length > MAX_MARKER_TAG_LENGTH + 2) {
172
182
  const tag = classifyMarkerTag(this.pending.slice(1));
173
183
  if (tag.kind === "suspicious" || tag.kind === "known") {
184
+ this.rejected = true;
174
185
  this.block = "";
175
186
  this.state = "outside";
176
187
  }
package/dist/i18n.js CHANGED
@@ -38,6 +38,12 @@ const zh = {
38
38
  "Starting computer setup pairing...": "正在发起电脑接入授权…",
39
39
  "Token setup does not require browser authorization. CREW_MACHINE_TOKEN is also supported.": "Token 接入无需浏览器授权,也支持 CREW_MACHINE_TOKEN。",
40
40
  "Checking computer connection information...": "正在检查接入信息…",
41
+ "Checking the existing computer connection...": "正在检查已有电脑连接…",
42
+ "Downloading the Daemon package...": "正在下载 Daemon 安装包…",
43
+ "Configuring the Daemon service...": "正在配置 Daemon 服务…",
44
+ "Starting the Daemon service...": "正在启动 Daemon 服务…",
45
+ "This computer is already connected. No installation changes are needed.": "此电脑已经连接,无需修改安装。",
46
+ "Setup is still in progress; check the original terminal and wait for it to finish": "接入仍在进行中,请查看原来的终端并等待接入完成。",
41
47
  "Downloading, installing, and starting the Daemon service...": "正在下载、安装并启动 Daemon 服务…",
42
48
  "Confirming the computer connection...": "正在确认电脑连接…",
43
49
  "Connected to {{workspace}}.": "已接入 {{workspace}}。",
@@ -962,17 +962,22 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
962
962
  await writeFile(legacyCwdMarker, "1", "utf8");
963
963
  }
964
964
  }
965
+ const externalAnswer = runtime.name === "pi"
966
+ ? externalOutput.lastClosedBlock
967
+ : externalOutput.lastClosedBlock
968
+ ?? (finalText === null ? null : extractExternalAnswer(finalText));
969
+ const reportFinalText = runtime.name === "pi" && externalAnswer === null
970
+ ? null
971
+ : finalText;
965
972
  const cloudReportResult = exitCode === 0
966
973
  ? await deliverCloudReports({
967
- facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
974
+ facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText: reportFinalText,
968
975
  receiptFile: reportContext.receiptFile,
969
- externalAnswer: externalOutput.lastClosedBlock
970
- ?? (finalText === null ? null : extractExternalAnswer(finalText)),
976
+ externalAnswer,
971
977
  captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
972
978
  ...(dependencies.uploadCloudReports === undefined ? {} : { upload: dependencies.uploadCloudReports }),
973
979
  })
974
- : { finalText, externalAnswer: externalOutput.lastClosedBlock
975
- ?? (finalText === null ? null : extractExternalAnswer(finalText)) };
980
+ : { finalText: reportFinalText, externalAnswer };
976
981
  memoryPruneExecutorCompleted = true;
977
982
  localMemoryTelemetry?.markExecutorCompleted();
978
983
  return {
@@ -988,7 +993,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
988
993
  errorMessage: errorTail || null,
989
994
  ...(providerError === undefined ? {} : { providerError }),
990
995
  finalText: input.captureFinal && cloudReportResult.finalText !== null
991
- ? stripExternalAnswerMarkers(cloudReportResult.finalText)
996
+ ? runtime.name === "pi" && cloudReportResult.externalAnswer === null
997
+ ? null
998
+ : stripExternalAnswerMarkers(cloudReportResult.finalText)
992
999
  : null,
993
1000
  externalAnswer: input.captureFinal ? cloudReportResult.externalAnswer : null,
994
1001
  sentViaCrew,
package/dist/main.js CHANGED
File without changes
@@ -1,17 +1,85 @@
1
- export function buildPiArgs(options) { return ["--mode", "json", "--print", "--no-session", "--system-prompt", options.systemPrompt, ...(options.model ? ["--model", options.model] : []), ...(options.reasoning && options.reasoning !== "default" ? ["--thinking", options.reasoning] : [])]; }
2
- export function createPiEventDecoder() { let sessionId = null; let finalText = ""; let error = null; let sawTurnEnd = false; let usage; return { push(raw) { const e = (raw ?? {}); const out = []; if (e.type === "session" && typeof e.id === "string") {
3
- sessionId = e.id;
4
- out.push({ type: "thread.started", thread_id: e.id });
5
- } const ae = e.assistantMessageEvent; if (e.type === "message_update" && ae?.type === "text_delta" && typeof ae.delta === "string") {
6
- finalText += ae.delta;
7
- out.push({ type: "pi.text_delta", text: ae.delta });
8
- } if (e.type === "message_update" && typeof ae?.type === "string" && /tool(call|_call)/iu.test(ae.type))
9
- out.push({ type: "pi.tool_call", ...(ae.name ? { title: ae.name } : {}) }); if (e.type === "message_end" && e.message?.role === "assistant")
10
- usage = e.message.usage; if (e.type === "turn_end") {
11
- sawTurnEnd = true;
12
- const u = usage;
13
- out.push({ type: "turn.completed", ...(u ? { usage: { input_tokens: u.input ?? 0, output_tokens: u.output ?? 0, cache_read_input_tokens: u.cacheRead ?? 0, cache_creation_input_tokens: u.cacheWrite ?? 0 } } : {}) });
14
- } if (e.type === "error" || e.type === "agent_error") {
15
- error = typeof e.error === "string" ? e.error : (e.error?.message ?? "Pi error");
16
- out.push({ type: "pi.error", message: error });
17
- } return out; }, finish() { return { ok: error === null && sawTurnEnd, ...(error === null && sawTurnEnd ? {} : { error: error ?? "Pi stream ended without turn completion" }), finalText, sessionId, usage }; } }; }
1
+ export function buildPiArgs(options) {
2
+ return ["--mode", "json", "--print", "--no-session", "--system-prompt", options.systemPrompt,
3
+ ...(options.model ? ["--model", options.model] : []),
4
+ ...(options.reasoning && options.reasoning !== "default" ? ["--thinking", options.reasoning] : [])];
5
+ }
6
+ // 保留稳定的原因标识供 server 本地化;不要用 token 数量猜测结束原因。
7
+ function failureReason(message) {
8
+ const reason = message?.stopReason;
9
+ return reason === "stop" || reason === "toolUse" ? null : `pi_runtime_failed: ${reason ?? "incomplete"}`;
10
+ }
11
+ export function createPiEventDecoder() {
12
+ let sessionId = null;
13
+ let finalText = "";
14
+ let error = null;
15
+ let lastAssistant = null;
16
+ let assistantOpen = false;
17
+ let sawTurnEnd = false;
18
+ let usage;
19
+ return {
20
+ push(raw) {
21
+ const e = (raw ?? {});
22
+ const out = [];
23
+ if (e.type === "session" && typeof e.id === "string") {
24
+ sessionId = e.id;
25
+ out.push({ type: "thread.started", thread_id: e.id });
26
+ }
27
+ if (e.type === "turn_start")
28
+ sawTurnEnd = false;
29
+ if ((e.type === "message_start" || e.type === "message_update") && e.message?.role === "assistant") {
30
+ assistantOpen = true;
31
+ sawTurnEnd = false;
32
+ }
33
+ const ae = e.assistantMessageEvent;
34
+ if (e.type === "message_update" && ae?.type === "text_delta" && typeof ae.delta === "string") {
35
+ finalText += ae.delta;
36
+ out.push({ type: "pi.text_delta", text: ae.delta });
37
+ }
38
+ if (e.type === "message_update" && ae?.type === "thinking_delta" && typeof ae.delta === "string") {
39
+ out.push({ type: "pi.thinking_delta", text: ae.delta });
40
+ }
41
+ if (e.type === "message_update" && typeof ae?.type === "string" && /tool(call|_call)/iu.test(ae.type)) {
42
+ out.push({ type: "pi.tool_call", ...(ae.name ? { title: ae.name } : {}) });
43
+ }
44
+ if (e.type === "message_end" && e.message?.role === "assistant") {
45
+ lastAssistant = e.message;
46
+ usage = e.message.usage;
47
+ assistantOpen = false;
48
+ sawTurnEnd = false;
49
+ }
50
+ if (e.type === "turn_end") {
51
+ // 原生 turn_end 自带本轮消息,不能把工具调用轮的结束当成整次执行成功。
52
+ if (e.message?.role === "assistant") {
53
+ lastAssistant = e.message;
54
+ usage = e.message.usage;
55
+ assistantOpen = false;
56
+ }
57
+ else if (e.message) {
58
+ lastAssistant = null;
59
+ }
60
+ sawTurnEnd = true;
61
+ const normalizedUsage = usage ? { usage: {
62
+ input_tokens: usage.input ?? 0, output_tokens: usage.output ?? 0,
63
+ cache_read_input_tokens: usage.cacheRead ?? 0, cache_creation_input_tokens: usage.cacheWrite ?? 0,
64
+ } } : {};
65
+ const failure = failureReason(lastAssistant);
66
+ out.push(failure
67
+ ? { type: "result", is_error: true, result: failure, ...normalizedUsage }
68
+ : { type: "turn.completed", ...normalizedUsage });
69
+ }
70
+ if (e.type === "error" || e.type === "agent_error") {
71
+ error = typeof e.error === "string" ? e.error : (e.error?.message ?? "Pi error");
72
+ out.push({ type: "pi.error", message: error });
73
+ }
74
+ return out;
75
+ },
76
+ finish() {
77
+ // 正常 stop 可为空(任务可能要求静默);length/error/aborted 必须明确失败。
78
+ const failure = error ?? (assistantOpen
79
+ ? "pi_runtime_failed: incomplete"
80
+ : failureReason(lastAssistant)
81
+ ?? (!sawTurnEnd || lastAssistant?.stopReason === "toolUse" ? "pi_runtime_failed: incomplete" : null));
82
+ return { ok: failure === null, ...(failure ? { error: failure } : {}), finalText, sessionId, usage };
83
+ },
84
+ };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.98-beta.3",
3
+ "version": "0.6.98-beta.5",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -18,13 +18,6 @@
18
18
  "publishConfig": {
19
19
  "access": "public"
20
20
  },
21
- "scripts": {
22
- "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
23
- "build": "tsc -p tsconfig.json",
24
- "prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
25
- "test": "vitest run",
26
- "typecheck": "tsc --noEmit"
27
- },
28
21
  "dependencies": {
29
22
  "@agentclientprotocol/sdk": "1.2.1",
30
23
  "@nowcrew/cli": "0.4.17",
@@ -46,5 +39,11 @@
46
39
  "tsx": "^4.19.0",
47
40
  "typescript": "^5.6.0",
48
41
  "vitest": "^2.1.0"
42
+ },
43
+ "scripts": {
44
+ "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
45
+ "build": "tsc -p tsconfig.json",
46
+ "test": "vitest run",
47
+ "typecheck": "tsc --noEmit"
49
48
  }
50
- }
49
+ }