@nanhara/hara 0.121.1 → 0.122.1

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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -14,6 +14,25 @@ export class SessionHub {
14
14
  constructor(store = realStore) {
15
15
  this.store = store;
16
16
  }
17
+ /** Mutate an on-disk session under the same single-writer lock used by live sessions. The load happens
18
+ * only AFTER acquisition, so a writer that finished immediately before us cannot be overwritten by a
19
+ * stale pre-lock snapshot. */
20
+ mutateStored(id, mutate) {
21
+ const lock = this.store.acquire(id);
22
+ if (!lock.ok)
23
+ return false;
24
+ try {
25
+ const current = this.store.load(id);
26
+ if (!current)
27
+ return false;
28
+ mutate(current);
29
+ this.store.save(current.meta, current.history);
30
+ return true;
31
+ }
32
+ finally {
33
+ this.store.release(id);
34
+ }
35
+ }
17
36
  create(o) {
18
37
  const meta = {
19
38
  id: newSessionId(),
@@ -25,33 +44,66 @@ export class SessionHub {
25
44
  updatedAt: "",
26
45
  source: "interactive", // serve sessions are user-driven (desktop/IDE clients)
27
46
  };
28
- this.store.acquire(meta.id); // fresh id always ours; registers the single-writer claim
29
- const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
30
- this.sessions.set(meta.id, s);
31
- return s;
47
+ const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
48
+ if (!lock.ok)
49
+ throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
50
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null };
51
+ try {
52
+ this.sessions.set(meta.id, s);
53
+ this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
54
+ return s;
55
+ }
56
+ catch (error) {
57
+ this.sessions.delete(meta.id);
58
+ this.store.release(meta.id);
59
+ throw error;
60
+ }
32
61
  }
33
62
  /** Resume a persisted session. Returns the live session, or a lock/missing failure. */
34
63
  resume(id, o) {
35
64
  const live = this.sessions.get(id);
65
+ if (live?.busy || live?.configuring)
66
+ return { busy: true };
36
67
  if (live)
37
68
  return { session: live }; // already attached to this server
38
- const prior = this.store.load(id);
39
- if (!prior)
40
- return { missing: true };
41
69
  const lock = this.store.acquire(id);
42
70
  if (!lock.ok)
43
71
  return { lockedBy: lock.pid ?? 0 };
44
- const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
45
- // A rotated config may change the provider/model between runs. The resumed transcript is preserved,
46
- // while new turns and subsequent persistence accurately reflect the live route.
47
- s.meta.provider = o.provider.id;
48
- s.meta.model = o.provider.model;
49
- this.sessions.set(id, s);
50
- return { session: s };
72
+ let keepLock = false;
73
+ try {
74
+ const prior = this.store.load(id); // lock-before-load: this is the authoritative latest snapshot
75
+ if (!prior)
76
+ return { missing: true };
77
+ // Credential/provider routing is live, while the model remains the session's explicit pin.
78
+ prior.meta.provider = o.provider.id;
79
+ const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null, effort: prior.meta.effort };
80
+ this.sessions.set(id, s);
81
+ keepLock = true; // live session owns it until delete/releaseAll
82
+ return { session: s };
83
+ }
84
+ finally {
85
+ if (!keepLock)
86
+ this.store.release(id);
87
+ }
51
88
  }
52
89
  get(id) {
53
90
  return this.sessions.get(id);
54
91
  }
92
+ /** Drop an attached but idle session and release its lock without deleting persistence. This is used
93
+ * when resume attached successfully but live provider validation failed before the client got a handle. */
94
+ detach(id) {
95
+ const live = this.sessions.get(id);
96
+ if (!live || live.busy || live.configuring)
97
+ return false;
98
+ this.sessions.delete(id);
99
+ this.store.release(id);
100
+ return true;
101
+ }
102
+ /** Read model/cwd routing metadata without attaching the session. The authoritative resume still reloads
103
+ * after acquiring its lock; callers use this only to build the likely provider before that handoff. */
104
+ peekMeta(id) {
105
+ return this.sessions.get(id)?.meta ?? this.store.load(id)?.meta;
106
+ }
55
107
  list(cwd) {
56
108
  return this.store.list(cwd);
57
109
  }
@@ -68,36 +120,36 @@ export class SessionHub {
68
120
  rename(id, title) {
69
121
  const live = this.sessions.get(id);
70
122
  if (live) {
123
+ if (live.busy || live.configuring)
124
+ return false;
71
125
  live.meta.title = title;
72
126
  this.store.save(live.meta, live.history);
73
127
  return true;
74
128
  }
75
- const prior = this.store.load(id);
76
- if (!prior)
77
- return false;
78
- prior.meta.title = title;
79
- this.store.save(prior.meta, prior.history);
80
- return true;
129
+ return this.mutateStored(id, (current) => {
130
+ current.meta.title = title;
131
+ });
81
132
  }
82
133
  /** Archive/unarchive (hidden from lists, kept on disk). Returns false when unknown. */
83
134
  setArchived(id, on) {
84
135
  const live = this.sessions.get(id);
85
136
  if (live) {
137
+ if (live.busy || live.configuring)
138
+ return false;
86
139
  live.meta.archived = on;
87
140
  this.store.save(live.meta, live.history);
88
141
  return true;
89
142
  }
90
- const prior = this.store.load(id);
91
- if (!prior)
92
- return false;
93
- prior.meta.archived = on;
94
- this.store.save(prior.meta, prior.history);
95
- return true;
143
+ return this.mutateStored(id, (current) => {
144
+ current.meta.archived = on;
145
+ });
96
146
  }
97
147
  /** Fork: duplicate a session's history into a NEW session (codex thread/fork) — the non-destructive
98
148
  * sibling of rewind. Source may be live or on-disk; the fork is always a fresh live session. */
99
149
  fork(id, o) {
100
150
  const live = this.sessions.get(id);
151
+ if (live?.busy || live?.configuring)
152
+ return { busy: true };
101
153
  const src = live ?? this.store.load(id);
102
154
  if (!src)
103
155
  return { missing: true };
@@ -105,13 +157,18 @@ export class SessionHub {
105
157
  id: newSessionId(),
106
158
  cwd: src.meta.cwd,
107
159
  provider: o.providerId,
108
- model: o.provider.model,
160
+ model: src.meta.model,
109
161
  title: src.meta.title ? `${src.meta.title} ⑂` : "",
110
162
  createdAt: new Date().toISOString(),
111
163
  updatedAt: "",
112
164
  source: "interactive",
165
+ ...(src.meta.workingSet ? { workingSet: [...src.meta.workingSet] } : {}),
166
+ ...(src.meta.todos ? { todos: src.meta.todos.map((todo) => ({ ...todo, ...(todo.blockedBy ? { blockedBy: [...todo.blockedBy] } : {}) })) } : {}),
167
+ ...(src.meta.effort ? { effort: src.meta.effort } : {}),
113
168
  };
114
- this.store.acquire(meta.id);
169
+ const lock = this.store.acquire(meta.id);
170
+ if (!lock.ok)
171
+ throw new Error(`could not acquire fork lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
115
172
  const s = {
116
173
  meta,
117
174
  history: [...src.history],
@@ -121,20 +178,30 @@ export class SessionHub {
121
178
  stats: { input: 0, output: 0 },
122
179
  projectContext: o.projectContext,
123
180
  busy: false,
181
+ configuring: false,
182
+ pendingProviderTurns: 0,
124
183
  abort: null,
184
+ effort: src.meta.effort,
125
185
  };
126
- this.sessions.set(meta.id, s);
127
- this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
128
- return { session: s };
186
+ try {
187
+ this.sessions.set(meta.id, s);
188
+ this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
189
+ return { session: s };
190
+ }
191
+ catch (error) {
192
+ this.sessions.delete(meta.id);
193
+ this.store.release(meta.id);
194
+ throw error;
195
+ }
129
196
  }
130
197
  /** Permanently delete (live or on-disk). Refuses a busy live session. Returns:
131
198
  * "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
132
199
  delete(id) {
133
200
  const live = this.sessions.get(id);
134
- if (live?.busy)
201
+ if (live?.busy || live?.configuring)
135
202
  return "busy";
136
203
  const ok = this.store.delete(id);
137
- if (!ok && !live)
204
+ if (!ok)
138
205
  return "missing";
139
206
  if (live)
140
207
  this.sessions.delete(id);
@@ -147,4 +214,17 @@ export class SessionHub {
147
214
  this.store.release(id);
148
215
  this.sessions.clear();
149
216
  }
217
+ /** Snapshot live sessions for graceful shutdown/health handling. */
218
+ active() {
219
+ return [...this.sessions.values()];
220
+ }
221
+ /** Release only idle sessions; logical work and abandoned-but-physical provider turns retain their locks. */
222
+ releaseIdle() {
223
+ for (const [id, session] of this.sessions) {
224
+ if (session.busy || session.configuring || session.pendingProviderTurns > 0)
225
+ continue;
226
+ this.store.release(id);
227
+ this.sessions.delete(id);
228
+ }
229
+ }
150
230
  }