@tyvm/knowhow 0.0.131 → 0.0.134

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.131",
3
+ "version": "0.0.134",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -75,6 +75,13 @@ export abstract class BaseAgent implements IAgent {
75
75
 
76
76
  // Interrupt support: resolves the currently awaited tool call or completion
77
77
  private _interruptResolve: (() => void) | null = null;
78
+ // A monotonically-increasing token identifying the active interruptible window.
79
+ // Used to ensure a stale (slow) operation completing in the background cannot
80
+ // clobber a newer interruptible window's resolver.
81
+ private _interruptToken = 0;
82
+ // Set when interrupt() is called with no active window, so the next
83
+ // makeInterruptible fires immediately instead of dropping the interrupt.
84
+ private _pendingInterrupt = false;
78
85
 
79
86
  protected threads = [] as Message[][];
80
87
 
@@ -217,6 +224,11 @@ export abstract class BaseAgent implements IAgent {
217
224
  this.startTimeMs = Date.now();
218
225
  this.currentTaskId = taskId || this.startTimeMs.toString();
219
226
 
227
+ // Reset interrupt state so a queued/stale interrupt from a prior task
228
+ // can't fire against the new task.
229
+ this._interruptResolve = null;
230
+ this._pendingInterrupt = false;
231
+
220
232
  // Emit event for plugin integration
221
233
  const id = taskId || this.startTimeMs.toString();
222
234
  this.events.emit(this.eventTypes.agentNewTask, {
@@ -740,32 +752,43 @@ export abstract class BaseAgent implements IAgent {
740
752
  interruptValue: T
741
753
  ): Promise<T> {
742
754
  return new Promise<T>((resolve, reject) => {
743
- let interrupted = false;
744
-
745
- this._interruptResolve = () => {
746
- interrupted = true;
747
- this._interruptResolve = null;
748
- resolve(interruptValue);
755
+ // Each interruptible window gets a unique token. `settled` guards this
756
+ // specific window so it can only ever resolve/reject once, and the token
757
+ // ensures a stale (slow) background operation that finishes AFTER this
758
+ // window was interrupted cannot clobber a NEWER window's resolver.
759
+ const myToken = ++this._interruptToken;
760
+ let settled = false;
761
+
762
+ const finish = (fn: () => void) => {
763
+ if (settled) return;
764
+ settled = true;
765
+ // Only clear the shared resolver if it still belongs to this window.
766
+ if (this._interruptToken === myToken) {
767
+ this._interruptResolve = null;
768
+ }
769
+ fn();
749
770
  };
750
771
 
772
+ this._interruptResolve = () => finish(() => resolve(interruptValue));
773
+
774
+ // If an interrupt arrived while there was no active window, honor it now.
775
+ if (this._pendingInterrupt) {
776
+ this._pendingInterrupt = false;
777
+ finish(() => resolve(interruptValue));
778
+ return;
779
+ }
780
+
751
781
  promise
752
782
  .then((result) => {
753
- if (this._interruptResolve) {
754
- this._interruptResolve = null;
755
- resolve(result);
756
- }
783
+ // If this window was already interrupted/settled, drop the stale
784
+ // result — do NOT touch _interruptResolve (it may belong to a newer
785
+ // window now).
786
+ finish(() => resolve(result));
757
787
  })
758
788
  .catch((err) => {
759
- if (this._interruptResolve) {
760
- this._interruptResolve = null;
761
- }
762
- // Only swallow the error if interrupt() was explicitly called.
763
- // Otherwise re-throw so callers see the real error.
764
- if (interrupted) {
765
- resolve(interruptValue);
766
- } else {
767
- reject(err);
768
- }
789
+ // If the window was already interrupted, swallow the late error.
790
+ if (settled) return;
791
+ finish(() => reject(err));
769
792
  });
770
793
  });
771
794
  }
@@ -777,10 +800,29 @@ export abstract class BaseAgent implements IAgent {
777
800
  */
778
801
  interrupt(message = "User interrupted this action you were waiting on") {
779
802
  this.log(`Interrupting current operation: ${message}`);
803
+
804
+ // If the user supplied a real message with the poke (not the default
805
+ // interrupt reason), queue it as a pending user message so the agent
806
+ // actually sees what they said on its next step.
807
+ if (
808
+ message &&
809
+ message !== "User interrupted this action you were waiting on"
810
+ ) {
811
+ this.addPendingUserMessage({ role: "user", content: message });
812
+ }
813
+
780
814
  if (this._interruptResolve) {
781
815
  this._interruptResolve();
782
816
  } else {
783
- this.log("No active interruptible operation to interrupt", "warn");
817
+ // No active interruptible window right now (we're between the AI
818
+ // completion and the tool call, or in message processing). Queue the
819
+ // interrupt so the next makeInterruptible window fires immediately
820
+ // instead of silently dropping it.
821
+ this._pendingInterrupt = true;
822
+ this.log(
823
+ "No active interruptible operation — queued interrupt for next step",
824
+ "warn"
825
+ );
784
826
  }
785
827
  }
786
828
 
package/src/cli.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  #!/usr/bin/env node --no-node-snapshot
2
2
  // Mark this process as running the Knowhow CLI (not just the SDK)
3
3
  process.env.KNOWHOW_CLI = "1";
4
+
5
+ // Determine which warnings to show based on the command being run.
6
+ // Commands that don't need AI models or a config file should not spam warnings.
7
+ // Individual commands can override by setting KNOWHOW_WARN_NEEDS before parsing.
8
+ // Format: comma-separated list of "models" | "config" (or empty string to suppress all).
9
+ const _rawArgs = process.argv.slice(2);
10
+ const _firstArg = _rawArgs.find((a) => !a.startsWith("-"));
11
+ const COMMANDS_NEEDING_MODELS = new Set(["chat", "agent", "ask", "generate", "embed", "replay"]);
12
+ const COMMANDS_NEEDING_CONFIG = new Set([
13
+ "chat", "agent", "ask", "generate", "embed", "upload", "download",
14
+ "worker", "workers", "replay", "behaviors", "skills",
15
+ ]);
16
+ const _needsModels = _firstArg && COMMANDS_NEEDING_MODELS.has(_firstArg) ? "models" : "";
17
+ const _needsConfig = _firstArg && COMMANDS_NEEDING_CONFIG.has(_firstArg) ? "config" : "";
18
+ const _needs = [_needsModels, _needsConfig].filter(Boolean).join(",");
19
+ process.env.KNOWHOW_WARN_NEEDS = process.env.KNOWHOW_WARN_NEEDS ?? _needs;
20
+
4
21
  import { Command } from "commander";
5
22
  import { version } from "../package.json";
6
23
  import { logger } from "./logger";
@@ -44,6 +61,7 @@ import { addBehaviorsCommand } from "./commands/behaviors";
44
61
  import { addSkillsCommand } from "./commands/skills";
45
62
  import { addAgentsCommand } from "./commands/agents";
46
63
  import { addHashCommand } from "./commands/hash";
64
+ import { addSandboxCommand } from "./commands/sandbox";
47
65
 
48
66
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
49
67
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -112,6 +130,7 @@ async function main() {
112
130
  addSkillsCommand(program);
113
131
  addAgentsCommand(program);
114
132
  addHashCommand(program);
133
+ addSandboxCommand(program);
115
134
 
116
135
  // Load global modules early (before parse) so they can register CLI subcommands.
117
136
  // We pass only the Program in context — no services are spun up at this stage.
@@ -311,7 +311,8 @@ export class AIClient {
311
311
  if (!client) {
312
312
  if (entry.provider === "knowhow") {
313
313
  if (process.env.KNOWHOW_CLI) {
314
- console.warn(
314
+ const warnNeeds = (process.env.KNOWHOW_WARN_NEEDS ?? "").split(",");
315
+ if (warnNeeds.includes("models")) console.warn(
315
316
  `⚠️ Knowhow provider is not logged in. Run 'knowhow login' to enable Knowhow models.`
316
317
  );
317
318
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import * as crypto from "crypto";
3
3
  import * as fs from "fs";
4
+ import { execSync } from "child_process";
4
5
  import { globSync } from "glob";
5
6
  import { getHashes, saveHashes } from "../hashes";
6
7
 
@@ -42,7 +43,16 @@ export function addHashCommand(program: Command): void {
42
43
  )
43
44
  .option(
44
45
  "--save",
45
- "Save the current hash after a successful build step."
46
+ "Save the current hash. In standalone mode, records the hash immediately (no command run). " +
47
+ "In --run mode, records the hash only if the command succeeds. " +
48
+ "Without --save in --run mode, the hash is never recorded — useful when --run is a " +
49
+ "side-effect trigger rather than the step that produces the tracked artifacts."
50
+ )
51
+ .option(
52
+ "--run <command>",
53
+ "Self-contained mode: if inputs are unchanged, skip and exit 0. " +
54
+ "If changed (or first run), run this shell command and — only if it " +
55
+ "succeeds — save the new hash. Replaces the '(hash) || (build && hash --save)' pattern."
46
56
  )
47
57
  .option("--verbose", "Print what files were found and the computed hash")
48
58
  .action(
@@ -51,6 +61,7 @@ export function addHashCommand(program: Command): void {
51
61
  input: string;
52
62
  output?: string;
53
63
  save?: boolean;
64
+ run?: string;
54
65
  verbose?: boolean;
55
66
  }) => {
56
67
  const hashes = await getHashes();
@@ -90,13 +101,16 @@ export function addHashCommand(program: Command): void {
90
101
  process.exit(0);
91
102
  }
92
103
 
93
- // Check mode: if any output file is missing, always report changed
104
+ // Determine whether inputs (or missing outputs) indicate a rebuild is needed.
105
+ let changed = false;
106
+
107
+ // If any output file is missing, always report changed
94
108
  for (const outPath of outputPaths) {
95
109
  if (!fs.existsSync(outPath)) {
96
110
  if (opts.verbose) {
97
111
  console.error(`[knowhow hash] output file missing: ${outPath} → changed`);
98
112
  }
99
- process.exit(1);
113
+ changed = true;
100
114
  }
101
115
  }
102
116
 
@@ -108,7 +122,47 @@ export function addHashCommand(program: Command): void {
108
122
  );
109
123
  }
110
124
 
111
- if (storedHash && storedHash === currentHash) {
125
+ if (!(storedHash && storedHash === currentHash)) {
126
+ changed = true;
127
+ }
128
+
129
+ // --run mode: self-contained. Skip when unchanged; otherwise run the
130
+ // command and only save the new hash if it succeeds.
131
+ if (opts.run) {
132
+ if (!changed) {
133
+ if (opts.verbose) {
134
+ console.error(`[knowhow hash] ✓ unchanged — skipping "${opts.run}"`);
135
+ }
136
+ process.exit(0);
137
+ }
138
+
139
+ if (opts.verbose) {
140
+ console.error(`[knowhow hash] ✗ changed — running "${opts.run}"`);
141
+ }
142
+ try {
143
+ execSync(opts.run, { stdio: "inherit", shell: "/bin/sh" });
144
+ } catch (err: any) {
145
+ // Command failed — do NOT save the hash so it retries next time.
146
+ process.exit(typeof err?.status === "number" ? err.status : 1);
147
+ }
148
+
149
+ // Command succeeded — only save the hash if --save was also passed.
150
+ // Without --save, the hash is NOT recorded so the command will re-run next time.
151
+ // Use --save when --run produces the artifacts that the hash tracks.
152
+ // Omit --save when --run is a side-effect trigger (e.g. snapshot regeneration).
153
+ if (opts.save) {
154
+ if (!hashes[opts.name]) hashes[opts.name] = {};
155
+ hashes[opts.name][opts.input] = currentHash;
156
+ await saveHashes(hashes);
157
+ if (opts.verbose) {
158
+ console.error(`[knowhow hash] saved hash ${currentHash} for "${opts.name}"`);
159
+ }
160
+ }
161
+ process.exit(0);
162
+ }
163
+
164
+ // Legacy check mode: exit 0 (unchanged) or 1 (changed).
165
+ if (!changed) {
112
166
  if (opts.verbose) {
113
167
  console.error(`[knowhow hash] ✓ unchanged — skipping`);
114
168
  }